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/README.md +157 -22
- package/dashboard/dashboard.html +1318 -834
- package/package.json +1 -1
- package/server.js +10 -1
- package/src/builder/build.js +121 -230
- package/src/check/check.js +137 -0
- package/src/cli/sandtable.js +36 -0
- 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
package/src/scanner/scan.js
CHANGED
|
@@ -3,198 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
+
const { loadContract, classifyFile } = require('../contract/loader');
|
|
6
7
|
|
|
7
|
-
// ----
|
|
8
|
-
const PATTERNS = [
|
|
9
|
-
{
|
|
10
|
-
name: 'decision',
|
|
11
|
-
test: (filename) => /^\d{4}-\d{2}-\d{2}-.+\.md$/.test(filename),
|
|
12
|
-
classify: () => ({ kind: 'primary', elementType: 'decision', category: 'decision' }),
|
|
13
|
-
},
|
|
14
|
-
{
|
|
15
|
-
name: 'agent',
|
|
16
|
-
test: (filename) => /^\d{2}-.+\.md$/.test(filename),
|
|
17
|
-
classify: () => ({ kind: 'primary', elementType: 'agent', category: 'ops' }),
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
name: 'prompt',
|
|
21
|
-
test: (filename) => /^[A-Z][A-Z_]+\.md$/.test(filename),
|
|
22
|
-
classify: () => ({ kind: 'secondary', elementType: 'prompt', category: 'spec' }),
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
name: 'template',
|
|
26
|
-
test: (filename) => filename.startsWith('_') && filename.endsWith('.md'),
|
|
27
|
-
classify: () => ({ kind: 'secondary', elementType: 'template', category: 'template' }),
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
name: 'refactor',
|
|
31
|
-
test: (filename) => /^r\d+-op\d+-.+\.md$/i.test(filename),
|
|
32
|
-
classify: () => ({ kind: 'primary', elementType: 'refactor', category: 'decision' }),
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
name: 'intent',
|
|
36
|
-
test: (filename) => /^[a-z][a-z_]+\.md$/.test(filename),
|
|
37
|
-
classify: () => ({ kind: 'secondary', elementType: 'intent', category: 'spec' }),
|
|
38
|
-
},
|
|
39
|
-
];
|
|
40
|
-
|
|
41
|
-
// ---- Primary types (structural, always-show) ----
|
|
42
|
-
const PRIMARY_TYPES = new Set([
|
|
43
|
-
'agent', 'decision', 'milestone', 'roadmap', 'refactor',
|
|
44
|
-
'phase', 'task', 'subtask', 'conclusion',
|
|
45
|
-
]);
|
|
46
|
-
|
|
47
|
-
// ---- MECE display categories (PM 6 大分类 + template) ----
|
|
48
|
-
// §10 libero PM 视角 — 9 大分类
|
|
49
|
-
// Primary-only 类别 (roadmap, decision) 不出现在 filterType checkbox 中
|
|
50
|
-
const DISPLAY_CATEGORIES = {
|
|
51
|
-
roadmap: { label: '路线图与进度', elementTypes: ['phase', 'milestone', 'task', 'subtask', 'roadmap', 'backlog', 'optimization', 'todo'] },
|
|
52
|
-
decision: { label: '决策记录', elementTypes: ['decision', 'refactor'] },
|
|
53
|
-
spec: { label: '业务规格', elementTypes: ['spec', 'intent', 'prompt'] },
|
|
54
|
-
convention: { label: '协作纪律', elementTypes: ['convention'] },
|
|
55
|
-
ops: { label: '运维与基建', elementTypes: ['agent', 'runbook'] },
|
|
56
|
-
archive: { label: '历史档案', elementTypes: ['journal', 'handover', 'plan_doc'] },
|
|
57
|
-
template: { label: '工具模板', elementTypes: ['template'] },
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
// Primary-only categories (all elementTypes in these categories are kind=primary, show always)
|
|
61
|
-
const PRIMARY_CATEGORIES = new Set(['roadmap', 'decision', 'todo']);
|
|
62
|
-
|
|
63
|
-
function normalizeCategory(elementType) {
|
|
64
|
-
for (const [cat, def] of Object.entries(DISPLAY_CATEGORIES)) {
|
|
65
|
-
if (def.elementTypes.includes(elementType)) return cat;
|
|
66
|
-
}
|
|
67
|
-
return 'unknown'; // 标记未知类型,不猜测
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// ---- Path-based refinement (directory semantics) ----
|
|
71
|
-
// Path hints refine category/elementType but never downgrade kind (primary → secondary)
|
|
72
|
-
// Sets _pathRefined flag to prevent content from overriding path-based decisions
|
|
73
|
-
function refineByPath(relativePath, current) {
|
|
74
|
-
const rp = relativePath.toLowerCase();
|
|
75
|
-
let result = null;
|
|
76
|
-
|
|
77
|
-
// Archive paths: journal/, handover/, plans/, archive/ → always secondary
|
|
78
|
-
if (/(^|\/)(journal|handover|plans|archive)(\/|$)/.test(rp)) {
|
|
79
|
-
result = { kind: 'secondary', elementType: 'journal', category: 'archive' };
|
|
80
|
-
}
|
|
81
|
-
// Runbook paths
|
|
82
|
-
else if (/(^|\/)runbooks?(\/|$)/.test(rp)) {
|
|
83
|
-
result = { kind: 'secondary', elementType: 'runbook', category: 'ops' };
|
|
84
|
-
}
|
|
85
|
-
// Decision paths → upgrade to primary
|
|
86
|
-
else if (/(^|\/)decisions?(\/|$)/.test(rp)) {
|
|
87
|
-
result = { kind: 'primary', elementType: 'decision', category: 'decision' };
|
|
88
|
-
}
|
|
89
|
-
// Skill paths + AGENTS.md + agents/ → collaboration (secondary)
|
|
90
|
-
else if (/(^|\/)skills?(\/|$)/.test(rp) || /(^|\/)agents?(\/|$)/.test(rp) || /^AGENTS\.md$/i.test(rp)) {
|
|
91
|
-
result = { kind: 'secondary', elementType: 'convention', category: 'convention' };
|
|
92
|
-
}
|
|
93
|
-
// Spec/refactor paths only refine when current type is weak or unknown
|
|
94
|
-
else if (/(^|\/)specs?(\/|$)/.test(rp) && current.elementType === 'unknown') {
|
|
95
|
-
result = { kind: 'secondary', elementType: 'spec', category: 'spec' };
|
|
96
|
-
}
|
|
97
|
-
else if (/(^|\/)refactors?(\/|$)/.test(rp) && current.elementType === 'unknown') {
|
|
98
|
-
result = { kind: 'primary', elementType: 'refactor', category: 'decision' };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
if (result) {
|
|
102
|
-
result._pathRefined = true;
|
|
103
|
-
return result;
|
|
104
|
-
}
|
|
105
|
-
return current;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// ---- Content-based refinement (auto-detection, no SUMMARY block) ----
|
|
109
|
-
function refineByContent(content, current, relativePath) {
|
|
110
|
-
const rp = relativePath || '';
|
|
111
|
-
|
|
112
|
-
// NEVER override archive classification (path-based takes priority)
|
|
113
|
-
if (current.category === 'archive') return current;
|
|
114
|
-
// Path-based reclassification is stronger than content guesswork
|
|
115
|
-
const canUpgradeKind = !current._pathRefined;
|
|
116
|
-
|
|
117
|
-
// H1 heading analysis
|
|
118
|
-
const h1Match = content.match(/^#\s+(.+)$/m);
|
|
119
|
-
if (h1Match) {
|
|
120
|
-
const h1 = h1Match[1];
|
|
121
|
-
|
|
122
|
-
if (canUpgradeKind && /@(PM|Architect|Coder|Tester|CloudOps|PromptAuthor)/.test(h1)) {
|
|
123
|
-
return { kind: 'primary', elementType: 'agent', category: 'ops' };
|
|
124
|
-
}
|
|
125
|
-
if (canUpgradeKind && /(Phase|第\s*\d+\s*批|阶段\s*\d+|Batch\s*\d+|^S\d+)/i.test(h1)) {
|
|
126
|
-
return { kind: 'primary', elementType: 'phase', category: 'roadmap' };
|
|
127
|
-
}
|
|
128
|
-
if (canUpgradeKind && /M\d+/.test(h1) && /(里程碑|Milestone|迁移)/i.test(h1)) {
|
|
129
|
-
return { kind: 'primary', elementType: 'milestone', category: 'roadmap' };
|
|
130
|
-
}
|
|
131
|
-
if (canUpgradeKind && /(结论|Conclusion|总结|Post-mortem|复盘)/.test(h1)) {
|
|
132
|
-
return { kind: 'primary', elementType: 'conclusion', category: 'roadmap' };
|
|
133
|
-
}
|
|
134
|
-
if (canUpgradeKind && /\bR\d+\b/.test(h1) && /\b(roadmap|实施|计划|Plan)\b/i.test(h1)) {
|
|
135
|
-
return { kind: 'primary', elementType: 'roadmap', category: 'roadmap' };
|
|
136
|
-
}
|
|
137
|
-
if (canUpgradeKind && /\bR\d+\b/.test(h1) && /\b(OP-\d+|spec|重构)\b/i.test(h1)) {
|
|
138
|
-
return { kind: 'primary', elementType: 'refactor', category: 'decision' };
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// 5-field journal detection → secondary
|
|
143
|
-
// Real journals have date in path (e.g. docs/journal/2026-05/2026-05-23-debug.md)
|
|
144
|
-
// Files like README.md that mention the 5-field format are conventions, not journals
|
|
145
|
-
const first500 = content.substring(0, 500);
|
|
146
|
-
const has5Fields =
|
|
147
|
-
/场景|卡点|错误/.test(first500) &&
|
|
148
|
-
/尝试/.test(first500) &&
|
|
149
|
-
/解法/.test(first500) &&
|
|
150
|
-
/harness.*建议/.test(first500);
|
|
151
|
-
if (has5Fields && /\d{4}-\d{2}/.test(rp)) {
|
|
152
|
-
return { kind: 'secondary', elementType: 'journal', category: 'archive' };
|
|
153
|
-
}
|
|
154
|
-
// If 5-field matched but no date in path → likely a journal README/convention
|
|
155
|
-
if (has5Fields && !/\d{4}-\d{2}/.test(rp)) {
|
|
156
|
-
return { kind: 'secondary', elementType: 'convention', category: 'convention' };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Convention table detection → secondary
|
|
160
|
-
if (/(简称|缩写|abbreviation|术语|term|定义|def)/i.test(first500)) {
|
|
161
|
-
return { kind: 'secondary', elementType: 'convention', category: 'convention' };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return current;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Apply SUMMARY block as fallback correction (only when auto-detection returned 'unknown')
|
|
168
|
-
function applySummaryCorrection(summary, current) {
|
|
169
|
-
if (!summary || !summary.type) return current;
|
|
170
|
-
// Correct when: auto-detection is unknown, OR SUMMARY declares a primary type (overrides filename pattern)
|
|
171
|
-
if (current.elementType === 'unknown' || PRIMARY_TYPES.has(summary.type)) {
|
|
172
|
-
const kind = summary.kind || (PRIMARY_TYPES.has(summary.type) ? 'primary' : 'secondary');
|
|
173
|
-
return { kind, elementType: summary.type, category: mapTypeToCategory(summary.type) };
|
|
174
|
-
}
|
|
175
|
-
// If auto-detection gave a secondary type but SUMMARY has kind override
|
|
176
|
-
if (summary.kind) {
|
|
177
|
-
return { kind: summary.kind, elementType: current.elementType, category: current.category };
|
|
178
|
-
}
|
|
179
|
-
return current;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function mapTypeToCategory(type) {
|
|
183
|
-
const map = {
|
|
184
|
-
phase: 'roadmap', milestone: 'roadmap', task: 'roadmap', subtask: 'roadmap',
|
|
185
|
-
roadmap: 'roadmap', backlog: 'roadmap', conclusion: 'roadmap',
|
|
186
|
-
decision: 'decision', refactor: 'decision',
|
|
187
|
-
spec: 'spec', intent: 'spec', prompt: 'spec',
|
|
188
|
-
convention: 'convention',
|
|
189
|
-
agent: 'ops', runbook: 'ops',
|
|
190
|
-
journal: 'archive', handover: 'archive', plan_doc: 'archive',
|
|
191
|
-
template: 'template',
|
|
192
|
-
};
|
|
193
|
-
return map[type] || 'unknown';
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// ---- Exclusion list (禁读清单) ----
|
|
197
|
-
// Simple glob → regex: supports ** (any depth), * (within segment), ? (single char)
|
|
8
|
+
// ---- Glob to Regex ----
|
|
198
9
|
function globToRegex(pattern) {
|
|
199
10
|
let re = '';
|
|
200
11
|
const parts = pattern.split('/');
|
|
@@ -210,13 +21,13 @@ function globToRegex(pattern) {
|
|
|
210
21
|
re += seg;
|
|
211
22
|
}
|
|
212
23
|
}
|
|
213
|
-
// '**' at end should match everything under that directory
|
|
214
24
|
if (pattern.endsWith('/**')) {
|
|
215
25
|
re += '(?:\\/.*)?';
|
|
216
26
|
}
|
|
217
27
|
return new RegExp('^' + re + '$', 'i');
|
|
218
28
|
}
|
|
219
29
|
|
|
30
|
+
// ---- Exclusion list ----
|
|
220
31
|
function applyExclusions(files, excludeConfig, projectRoot) {
|
|
221
32
|
if (!excludeConfig) return files;
|
|
222
33
|
|
|
@@ -294,31 +105,37 @@ function extractTimeInfo(content, filename, fileModified) {
|
|
|
294
105
|
return { date: mtimeISO, dateSource: 'mtime' };
|
|
295
106
|
}
|
|
296
107
|
|
|
297
|
-
// ----
|
|
298
|
-
function
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
108
|
+
// ---- Build display categories from contract ----
|
|
109
|
+
function buildDisplayCategories(contract) {
|
|
110
|
+
if (!contract || !contract.categories) return {};
|
|
111
|
+
const result = {};
|
|
112
|
+
for (const cat of contract.categories) {
|
|
113
|
+
result[cat.id] = {
|
|
114
|
+
label: cat.label,
|
|
115
|
+
elementTypes: [],
|
|
116
|
+
defaultHidden: cat.defaultHidden || false
|
|
117
|
+
};
|
|
303
118
|
}
|
|
304
|
-
return
|
|
119
|
+
return result;
|
|
305
120
|
}
|
|
306
121
|
|
|
307
122
|
// ---- Main scan ----
|
|
308
123
|
function scan(projectRoot) {
|
|
309
|
-
const config =
|
|
124
|
+
const { contract, config } = loadContract(projectRoot);
|
|
310
125
|
const mdFiles = walkMd(projectRoot);
|
|
311
126
|
|
|
312
|
-
// Build file objects with relative paths for exclusion checking
|
|
313
127
|
let fileEntries = mdFiles.map(f => ({
|
|
314
128
|
full: f,
|
|
315
129
|
relative: path.relative(projectRoot, f).replace(/\\/g, '/'),
|
|
316
130
|
}));
|
|
317
131
|
|
|
318
|
-
// Apply exclusion list
|
|
319
|
-
fileEntries = applyExclusions(fileEntries, config
|
|
132
|
+
// Apply exclusion list
|
|
133
|
+
fileEntries = applyExclusions(fileEntries, config._exclude, projectRoot);
|
|
320
134
|
|
|
321
|
-
const files =
|
|
135
|
+
const files = [];
|
|
136
|
+
const metaFiles = [];
|
|
137
|
+
|
|
138
|
+
for (const fe of fileEntries) {
|
|
322
139
|
const f = fe.full;
|
|
323
140
|
const relative = fe.relative;
|
|
324
141
|
const filename = path.basename(f);
|
|
@@ -326,40 +143,16 @@ function scan(projectRoot) {
|
|
|
326
143
|
const content = fs.readFileSync(f, 'utf-8');
|
|
327
144
|
const summary = extractSummary(content);
|
|
328
145
|
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
let meta = {}; // carries flags like _pathRefined across the chain
|
|
332
|
-
|
|
333
|
-
// Step 2: path-based refinement (directory semantics)
|
|
334
|
-
const pathRefined = refineByPath(relative, { kind, elementType, category });
|
|
335
|
-
kind = pathRefined.kind;
|
|
336
|
-
elementType = pathRefined.elementType;
|
|
337
|
-
category = pathRefined.category;
|
|
338
|
-
if (pathRefined._pathRefined) meta._pathRefined = true;
|
|
339
|
-
|
|
340
|
-
// Step 3: auto-detection by content signals
|
|
341
|
-
const refined = refineByContent(content, { kind, elementType, category, _pathRefined: meta._pathRefined }, relative);
|
|
342
|
-
kind = refined.kind;
|
|
343
|
-
elementType = refined.elementType;
|
|
344
|
-
category = refined.category;
|
|
146
|
+
// docContract-driven classification
|
|
147
|
+
const classification = classifyFile(relative, contract, summary);
|
|
345
148
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
elementType = corrected.elementType;
|
|
350
|
-
category = corrected.category;
|
|
351
|
-
|
|
352
|
-
// Step 5: config paths override category
|
|
353
|
-
if (config && config.paths) {
|
|
354
|
-
for (const [cat, cfgPath] of Object.entries(config.paths)) {
|
|
355
|
-
const normalizedCfg = cfgPath.replace(/\\/g, '/');
|
|
356
|
-
if (relative.startsWith(normalizedCfg) || relative === normalizedCfg) {
|
|
357
|
-
category = cat;
|
|
358
|
-
break;
|
|
359
|
-
}
|
|
360
|
-
}
|
|
149
|
+
if (!classification || classification.isMeta) {
|
|
150
|
+
metaFiles.push({ path: relative, reason: classification ? 'meta' : 'excluded' });
|
|
151
|
+
continue;
|
|
361
152
|
}
|
|
362
153
|
|
|
154
|
+
const { category, elementType } = classification;
|
|
155
|
+
|
|
363
156
|
// Extract time info
|
|
364
157
|
const timeInfo = extractTimeInfo(content, filename, stat.mtime.toISOString());
|
|
365
158
|
|
|
@@ -367,24 +160,27 @@ function scan(projectRoot) {
|
|
|
367
160
|
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
368
161
|
const title = titleMatch ? titleMatch[1] : filename.replace(/\.md$/, '');
|
|
369
162
|
|
|
370
|
-
|
|
163
|
+
// Determine kind: roadmap, decision, todo → primary; others → secondary
|
|
164
|
+
const kind = (category === 'roadmap' || category === 'decision' || category === 'todo')
|
|
165
|
+
? 'primary' : 'secondary';
|
|
166
|
+
|
|
167
|
+
files.push({
|
|
371
168
|
path: relative,
|
|
372
169
|
filename,
|
|
373
170
|
category,
|
|
374
|
-
kind,
|
|
375
|
-
elementType,
|
|
376
|
-
type: elementType,
|
|
171
|
+
kind,
|
|
172
|
+
elementType,
|
|
173
|
+
type: elementType,
|
|
377
174
|
title,
|
|
378
175
|
size: stat.size,
|
|
379
176
|
modified: stat.mtime.toISOString(),
|
|
380
|
-
date: timeInfo.date,
|
|
381
|
-
dateSource: timeInfo.dateSource
|
|
177
|
+
date: timeInfo.date,
|
|
178
|
+
dateSource: timeInfo.dateSource,
|
|
382
179
|
hasSummary: summary !== null,
|
|
383
180
|
summary: summary || null,
|
|
384
|
-
};
|
|
385
|
-
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
386
183
|
|
|
387
|
-
// Collect unique element types (v2 additions)
|
|
388
184
|
const primaryCount = files.filter(f => f.kind === 'primary').length;
|
|
389
185
|
const secondaryCount = files.filter(f => f.kind === 'secondary').length;
|
|
390
186
|
|
|
@@ -392,20 +188,20 @@ function scan(projectRoot) {
|
|
|
392
188
|
scannedAt: new Date().toISOString(),
|
|
393
189
|
projectRoot,
|
|
394
190
|
totalFiles: files.length,
|
|
395
|
-
primaryCount,
|
|
396
|
-
secondaryCount,
|
|
191
|
+
primaryCount,
|
|
192
|
+
secondaryCount,
|
|
397
193
|
filesWithSummary: files.filter(f => f.hasSummary).length,
|
|
398
194
|
categories: [...new Set(files.map(f => f.category))],
|
|
399
|
-
elementTypes: [...new Set(files.map(f => f.elementType))],
|
|
195
|
+
elementTypes: [...new Set(files.map(f => f.elementType))],
|
|
196
|
+
metaExcluded: metaFiles.length,
|
|
400
197
|
files,
|
|
401
198
|
};
|
|
402
199
|
}
|
|
403
200
|
|
|
404
201
|
module.exports = {
|
|
405
|
-
scan, walkMd,
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
PRIMARY_CATEGORIES,
|
|
202
|
+
scan, walkMd, extractSummary, extractTimeInfo,
|
|
203
|
+
applyExclusions, loadConfig, buildDisplayCategories,
|
|
204
|
+
classifyFile: require('../contract/loader').classifyFile
|
|
409
205
|
};
|
|
410
206
|
|
|
411
207
|
if (require.main === module) {
|