sandtable 0.4.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sandtable",
3
- "version": "0.4.0",
3
+ "version": "1.0.1",
4
4
  "description": "AI 编程项目可视化指挥面板 — 双视图 dashboard,多源数据融合",
5
5
  "main": "src/cli/sandtable.js",
6
6
  "bin": {
package/server.js CHANGED
@@ -36,17 +36,26 @@ const MIME = {
36
36
  '.svg': 'image/svg+xml',
37
37
  };
38
38
 
39
- // Only serve from these two directories — project sources are not exposed
39
+ // Only serve from these directories — project sources are not exposed
40
40
  const ALLOWED_PREFIXES = [
41
41
  path.join(ROOT, 'dashboard'),
42
42
  path.join(ROOT, 'data'),
43
+ path.join(ROOT, 'docs'),
43
44
  ];
44
45
 
46
+ // Root-level files that are safe to serve (documentation, config)
47
+ const ROOT_ALLOWED = new Set([
48
+ 'AGENTS.md', 'CLAUDE.md', 'README.md', 'INSTALL.md',
49
+ 'CHANGELOG.md', 'CONTRIBUTING.md',
50
+ ]);
51
+
45
52
  function isAllowed(filePath) {
46
53
  if (!filePath.startsWith(ROOT)) return false;
47
54
  for (const prefix of ALLOWED_PREFIXES) {
48
55
  if (filePath.startsWith(prefix)) return true;
49
56
  }
57
+ // Allow specific root-level documentation files
58
+ if (ROOT_ALLOWED.has(path.relative(ROOT, filePath))) return true;
50
59
  return false;
51
60
  }
52
61
 
@@ -3,7 +3,9 @@
3
3
 
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
- const { scan, loadConfig, normalizeCategory, DISPLAY_CATEGORIES, PRIMARY_CATEGORIES } = require('../scanner/scan');
6
+ const { scan } = require('../scanner/scan');
7
+ const { buildTracks } = require('../progress/parser');
8
+ const { loadContract } = require('../contract/loader');
7
9
 
8
10
  // ---- Markdown Table Parser (unchanged from v1) ----
9
11
  function parseMarkdownTables(content) {
@@ -30,201 +32,45 @@ function parseMarkdownTables(content) {
30
32
  return results;
31
33
  }
32
34
 
33
- // ---- Primary Tree Builder ----
34
- function buildPrimaryTree(planFiles, allFiles, projectRoot) {
35
+ // ---- Tracks to Elements (converts parser output to timeline element format) ----
36
+ function tracksToElements(tracks) {
35
37
  const elements = [];
36
-
37
- // Collect task rows from task-board-like files
38
- const taskRows = [];
39
- for (const pf of planFiles) {
40
- const fp = path.join(projectRoot, pf.path);
41
- if (!fs.existsSync(fp)) continue;
42
- const content = fs.readFileSync(fp, 'utf-8');
43
- const tables = parseMarkdownTables(content);
44
- const hasTaskCols = tables.length > 0 &&
45
- (Object.keys(tables[0]).some(k => /任务|task|#/.test(k)));
46
- if (hasTaskCols) {
47
- for (const row of tables) {
48
- taskRows.push({ ...row, _source: pf.path });
49
- }
50
- }
51
- }
52
-
53
- // Build phases from milestone/plan files with markdown tables
54
- const milestoneFiles = planFiles.filter(f =>
55
- f.elementType === 'milestone' ||
56
- (f.summary && f.summary.type === 'milestone')
57
- );
58
-
59
- for (const mf of milestoneFiles) {
60
- const fp = path.join(projectRoot, mf.path);
61
- if (!fs.existsSync(fp)) continue;
62
- const content = fs.readFileSync(fp, 'utf-8');
63
- const rows = parseMarkdownTables(content);
64
-
65
- const milestones = rows.map((row, i) => {
66
- const keys = Object.keys(row);
67
- const milestoneName = row[keys[0]] || '';
68
- const version = row[keys[1]] || '';
69
- const signal = row[keys[2]] || '';
70
- const statusRaw = (row[keys[3]] || 'pending').toLowerCase();
71
- const status = statusRaw.includes('in_progress') ? 'in_progress'
72
- : statusRaw.includes('complete') ? 'completed'
73
- : statusRaw.includes('block') ? 'blocked'
74
- : 'pending';
75
-
76
- // Match subtasks from task board
77
- const subtasks = taskRows
78
- .filter(tr => {
79
- const taskName = tr[Object.keys(tr)[1]] || tr[Object.keys(tr)[0]] || '';
80
- return taskName.includes(milestoneName) ||
81
- milestoneName.includes(taskName.substring(0, 6));
82
- })
83
- .slice(0, 3)
84
- .map(tr => {
85
- const tKeys = Object.keys(tr);
86
- const statusKey = tKeys.find(k => /状态|status/i.test(k));
87
- const statusCol = statusKey ? tr[statusKey] : (tr[tKeys[4]] || tr[tKeys[3]] || '');
88
- const isCompleted = /✅|\[x\]|completed|complete/i.test(statusCol);
89
- const isInProgress = /⏳|\[\s*\]|in.progress/i.test(statusCol);
90
- const typeKey = tKeys.find(k => /类型|type/i.test(k));
91
- const typeCol = typeKey ? tr[typeKey] : (tr[tKeys[3]] || tr[tKeys[2]] || '');
92
- return {
93
- id: tr[tKeys[0]] || '',
94
- name: tr[tKeys[1]] || tr[tKeys[0]] || '',
95
- status: isCompleted ? 'completed' : isInProgress ? 'in_progress' : 'pending',
96
- kind: 'primary',
97
- elementType: 'subtask',
98
- timeGroup: 'current',
99
- timeLabel: '',
100
- date: null,
101
- source: { file: tr._source || mf.path, title: mf.title },
102
- summary: '',
103
- tags: [],
104
- related: [],
105
- children: [],
106
- };
107
- });
108
-
109
- return {
110
- id: 'ms-' + (milestoneName.replace(/^M/, '').substring(0, 10) || `M${i + 1}`),
111
- kind: 'primary',
112
- elementType: 'milestone',
113
- category: 'roadmap',
114
- name: version ? `${milestoneName} (${version})` : milestoneName,
115
- status,
116
- timeGroup: 'current',
117
- timeLabel: signal,
118
- date: mf.date || null,
119
- source: { file: mf.path, title: mf.title },
120
- summary: '',
121
- tags: [],
122
- related: [],
123
- children: subtasks,
124
- order: i + 1,
125
- };
126
- });
127
-
128
- elements.push({
129
- id: 'phase-' + (elements.length + 1),
130
- kind: 'primary',
131
- elementType: 'phase',
132
- category: 'roadmap',
133
- name: mf.summary ? mf.summary.summary : mf.title,
134
- status: mf.summary ? mf.summary.status : 'in_progress',
135
- timeGroup: 'current',
136
- timeLabel: '',
137
- date: mf.date || null,
138
- source: { file: mf.path, title: mf.title },
139
- summary: mf.summary ? (mf.summary.summary || '') : '',
140
- tags: mf.summary ? (mf.summary.tags || []) : [],
141
- related: mf.summary ? (mf.summary.related || []) : [],
142
- children: milestones,
143
- order: elements.length + 1,
144
- });
145
- }
146
-
147
- // Fallback: default phase from any plan-category files with summaries
148
- if (elements.length === 0 && planFiles.length > 0) {
149
- const tasks = planFiles
150
- .filter(f => f.summary)
151
- .map(f => ({
152
- id: 'task-' + f.path.replace(/[/.]/g, '-'),
153
- kind: 'primary',
154
- elementType: 'task',
155
- category: 'roadmap',
156
- name: f.summary.summary,
157
- status: f.summary.status || 'pending',
158
- timeGroup: 'current',
159
- timeLabel: '',
160
- date: f.date || null,
161
- source: { file: f.path, title: f.title },
162
- summary: f.summary.summary || '',
163
- tags: f.summary.tags || [],
164
- related: f.summary.related || [],
165
- children: [],
166
- }));
167
-
168
- elements.push({
169
- id: 'phase-default',
170
- kind: 'primary',
171
- elementType: 'phase',
172
- category: 'roadmap',
173
- name: '当前阶段',
174
- status: 'in_progress',
175
- timeGroup: 'current',
176
- timeLabel: '',
177
- date: null,
178
- source: { file: '', title: '' },
179
- summary: '',
180
- tags: [],
181
- related: [],
182
- children: [{
183
- id: 'ms-current',
38
+ for (const track of tracks) {
39
+ for (const phase of track.phases) {
40
+ elements.push({
41
+ id: 'ms-' + track.id + '-' + phase.id.replace(/[^a-zA-Z0-9]/g, '-'),
184
42
  kind: 'primary',
185
43
  elementType: 'milestone',
186
44
  category: 'roadmap',
187
- name: '进行中',
188
- status: 'in_progress',
45
+ name: phase.name,
46
+ status: phase.status,
189
47
  timeGroup: 'current',
190
- timeLabel: '',
191
- date: null,
192
- source: { file: '', title: '' },
48
+ timeLabel: phase.batch || '',
49
+ date: phase.date || null,
50
+ source: { file: track.source, title: phase.name },
193
51
  summary: '',
194
- tags: [],
52
+ tags: [track.id],
195
53
  related: [],
196
- children: tasks,
197
- order: 1,
198
- }],
199
- order: 1,
200
- });
201
- }
202
-
203
- // Extract conclusion elements from files with type=conclusion
204
- const conclusionFiles = allFiles.filter(f =>
205
- f.elementType === 'conclusion' ||
206
- (f.summary && f.summary.type === 'conclusion')
207
- );
208
- for (const cf of conclusionFiles) {
209
- elements.push({
210
- id: 'conclusion-' + cf.path.replace(/[/.]/g, '-'),
211
- kind: 'primary',
212
- elementType: 'conclusion',
213
- category: 'roadmap',
214
- name: cf.title,
215
- status: cf.summary ? (cf.summary.status || 'completed') : 'completed',
216
- timeGroup: 'past',
217
- timeLabel: cf.date || '',
218
- date: cf.date || null,
219
- source: { file: cf.path, title: cf.title },
220
- summary: cf.summary ? (cf.summary.summary || '') : '',
221
- tags: cf.summary ? (cf.summary.tags || []) : [],
222
- related: cf.summary ? (cf.summary.related || []) : [],
223
- children: [],
224
- order: 0,
225
- });
54
+ children: (phase.tasks || []).map(t => ({
55
+ id: 'task-' + t.id.replace(/[^a-zA-Z0-9]/g, '-'),
56
+ kind: 'primary',
57
+ elementType: 'subtask',
58
+ category: 'roadmap',
59
+ name: t.title,
60
+ status: t.status,
61
+ timeGroup: 'current',
62
+ timeLabel: '',
63
+ date: null,
64
+ source: { file: track.source, title: t.title },
65
+ summary: '',
66
+ tags: [track.id, t.actor].filter(Boolean),
67
+ related: [],
68
+ children: [],
69
+ })),
70
+ order: elements.length + 1,
71
+ });
72
+ }
226
73
  }
227
-
228
74
  return elements;
229
75
  }
230
76
 
@@ -278,25 +124,29 @@ function classifyTimeGroup(element, timeRules, today) {
278
124
 
279
125
  // ---- Filter Type Manifest Builder ----
280
126
  function buildFilterTypes(files, secondaryTypesDefaultOff) {
281
- const secondaryFiles = files.filter(f => f.kind === 'secondary');
282
- const catCounts = {};
283
-
284
- for (const f of secondaryFiles) {
285
- const cat = normalizeCategory(f.elementType);
286
- catCounts[cat] = (catCounts[cat] || 0) + 1;
287
- }
288
-
289
- const result = [];
290
- for (const [cat, def] of Object.entries(DISPLAY_CATEGORIES)) {
291
- // Skip primary-only categories (all their elementTypes are kind=primary, no secondary files)
292
- if (PRIMARY_CATEGORIES && PRIMARY_CATEGORIES.has(cat)) continue;
293
- const count = catCounts[cat] || 0;
294
- if (count > 0) {
295
- result.push({ type: cat, label: def.label, count, defaultEnabled: !secondaryTypesDefaultOff });
127
+ // Derive filter types from actual file data instead of hardcoded constants
128
+ const filterTypes = [];
129
+ const seen = new Set();
130
+ for (const f of files) {
131
+ const key = f.category + ':' + f.elementType;
132
+ if (!seen.has(key)) {
133
+ seen.add(key);
134
+ filterTypes.push({
135
+ category: f.category,
136
+ type: f.elementType,
137
+ label: (f.category || 'unknown') + ' ' + (f.elementType || 'unknown'),
138
+ count: 0,
139
+ defaultEnabled: !secondaryTypesDefaultOff
140
+ });
296
141
  }
297
142
  }
298
-
299
- return result;
143
+ // Count occurrences
144
+ for (const f of files) {
145
+ const key = f.category + ':' + f.elementType;
146
+ const ft = filterTypes.find(ft => (ft.category + ':' + ft.type) === key);
147
+ if (ft) ft.count++;
148
+ }
149
+ return filterTypes;
300
150
  }
301
151
 
302
152
  // ---- Conventions Builder (auto-extract from docs/conventions/) ----
@@ -768,21 +618,20 @@ function buildTimeline(files, projectRoot, config) {
768
618
  const displayCfg = (config && config.display) ? config.display : {};
769
619
  const maxBriefLength = displayCfg.maxBriefLength || 200;
770
620
 
771
- // Step 1: Build primary tree from roadmap-category files
772
- const planFiles = files.filter(f =>
773
- f.kind === 'primary' && (f.category === 'roadmap' || f.elementType === 'phase' ||
774
- f.elementType === 'milestone' || f.elementType === 'roadmap' || f.elementType === 'task' || f.elementType === 'subtask')
775
- );
621
+ // Step 1: Build tracks from progressSources (replaces buildPrimaryTree)
622
+ const progressSources = (config && config._progressSources) ? config._progressSources : [];
623
+ const { tracks, currentNode } = buildTracks(progressSources, projectRoot);
624
+
625
+ // Convert tracks to elements format for backward compatibility
626
+ const primaryTree = tracksToElements(tracks);
776
627
 
777
628
  // Also include decision/refactor/conclusion type primary files
778
629
  const otherPrimary = files.filter(f =>
779
- f.kind === 'primary' && !planFiles.includes(f) &&
630
+ f.kind === 'primary' &&
780
631
  (f.elementType === 'decision' || f.elementType === 'conclusion' ||
781
632
  f.elementType === 'refactor' || f.category === 'decision')
782
633
  );
783
634
 
784
- const primaryTree = buildPrimaryTree(planFiles, files, projectRoot);
785
-
786
635
  // Build primary flat list for non-plan primary files
787
636
  const primaryFlat = otherPrimary
788
637
  .filter(f => f.hasSummary || f.elementType !== 'unknown')
@@ -844,6 +693,8 @@ function buildTimeline(files, projectRoot, config) {
844
693
  elements: allElements,
845
694
  events,
846
695
  filterTypes,
696
+ tracks,
697
+ currentNode,
847
698
  display: {
848
699
  secondaryTypesDefaultOff: displayCfg.secondaryTypesDefaultOff !== false,
849
700
  conventionsManualOnly: displayCfg.conventionsManualOnly !== false,
@@ -854,34 +705,73 @@ function buildTimeline(files, projectRoot, config) {
854
705
 
855
706
  // ---- Backward Compatibility: timeline → roadmap.json ----
856
707
  function timelineToRoadmapCompat(timeline) {
857
- const phases = [];
858
- for (const el of timeline.elements) {
708
+ var phases = [];
709
+
710
+ // New: source phases from tracks when available
711
+ if (timeline.tracks && timeline.tracks.length > 0) {
712
+ for (var ti = 0; ti < timeline.tracks.length; ti++) {
713
+ var track = timeline.tracks[ti];
714
+ for (var pi = 0; pi < track.phases.length; pi++) {
715
+ var phase = track.phases[pi];
716
+ phases.push({
717
+ id: 'phase-' + track.id + '-' + phase.id.replace(/[^a-zA-Z0-9]/g, '-'),
718
+ name: phase.name,
719
+ status: phase.status,
720
+ order: phases.length + 1,
721
+ trackId: track.id,
722
+ milestones: [{
723
+ id: 'ms-' + track.id + '-' + phase.id.replace(/[^a-zA-Z0-9]/g, '-'),
724
+ name: phase.name,
725
+ status: phase.status,
726
+ owner: phase.batch || '',
727
+ subtasks: (phase.tasks || []).map(function(t) {
728
+ return {
729
+ id: 'task-' + t.id.replace(/[^a-zA-Z0-9]/g, '-'),
730
+ name: t.title,
731
+ status: t.status,
732
+ type: t.actor || 'hand',
733
+ };
734
+ }),
735
+ }],
736
+ });
737
+ }
738
+ }
739
+ }
740
+
741
+ // Fallback: old phase-based filtering (for non-track elements)
742
+ for (var i = 0; i < timeline.elements.length; i++) {
743
+ var el = timeline.elements[i];
859
744
  if (el.kind === 'primary' && el.elementType === 'phase') {
860
745
  phases.push({
861
746
  id: el.id,
862
747
  name: el.name,
863
748
  status: el.status,
864
749
  order: el.order || phases.length + 1,
865
- milestones: (el.children || []).map(ch => ({
866
- id: ch.id,
867
- name: ch.name,
868
- status: ch.status,
869
- owner: ch.source ? ch.source.file : '',
870
- subtasks: (ch.children || []).map(st => ({
871
- id: st.id,
872
- name: st.name,
873
- status: st.status,
874
- type: st.elementType || 'hand',
875
- })),
876
- })),
750
+ milestones: [{
751
+ id: el.id,
752
+ name: el.name,
753
+ status: el.status,
754
+ owner: el.source ? el.source.file : '',
755
+ subtasks: (el.children || []).map(function(st) {
756
+ return {
757
+ id: st.id,
758
+ name: st.name,
759
+ status: st.status,
760
+ type: st.elementType || 'hand',
761
+ };
762
+ }),
763
+ }],
877
764
  });
878
765
  }
879
766
  }
767
+
880
768
  return {
881
769
  project: timeline.project,
882
770
  updated: timeline.updated,
883
771
  brief: timeline.brief,
884
- phases,
772
+ tracks: timeline.tracks || [],
773
+ currentNode: timeline.currentNode || null,
774
+ phases: phases,
885
775
  };
886
776
  }
887
777
 
@@ -947,7 +837,7 @@ function buildTokenSummary(projectRoot, config) {
947
837
  function build(projectRoot) {
948
838
  const scanResult = scan(projectRoot);
949
839
  const { files } = scanResult;
950
- const config = loadConfig(projectRoot);
840
+ const { config } = loadContract(projectRoot);
951
841
 
952
842
  const timeline = buildTimeline(files, projectRoot, config);
953
843
  const conventions = buildConventions(files, projectRoot);
@@ -1006,10 +896,11 @@ function build(projectRoot) {
1006
896
  }
1007
897
 
1008
898
  module.exports = {
1009
- build, buildTimeline, buildPrimaryTree, buildSecondaryList,
899
+ build, buildTimeline, buildSecondaryList,
1010
900
  classifyTimeGroup, buildFilterTypes, buildConventions,
1011
901
  buildAgents, timelineToRoadmapCompat, timelineToJournalCompat,
1012
902
  EVENT_TYPES, classifyEventPriority, buildTokenSummary,
903
+ buildTracks, tracksToElements,
1013
904
  };
1014
905
 
1015
906
  if (require.main === module) {
@@ -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
+ }
@@ -814,6 +814,40 @@ switch (command) {
814
814
  initCommand(process.cwd(), applyMode, lang, hooksMode, injectAgentsMd);
815
815
  break;
816
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
+ }
817
851
  case 'summarize': {
818
852
  const scanResult = scan(root);
819
853
  const missing = scanResult.files.filter(f => !f.hasSummary);
@@ -1291,6 +1325,8 @@ case 'token-log': {
1291
1325
  console.log(' sandtable scan [projectRoot] 扫描文档目录');
1292
1326
  console.log(' sandtable build [projectRoot] 生成 data/*.json');
1293
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 回归测试');
1294
1330
  console.log(' sandtable summarize [projectRoot] 列出缺少摘要块的文件');
1295
1331
  console.log(' sandtable event-log <typeId> <title> [...] 记录人机协同事件');
1296
1332
  console.log(' sandtable token-log <skill> <in> <out> [...] 追加 token 消耗日志');