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,415 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
// ---- Built-in patterns: two-tier (primary vs secondary) ----
|
|
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)
|
|
198
|
+
function globToRegex(pattern) {
|
|
199
|
+
let re = '';
|
|
200
|
+
const parts = pattern.split('/');
|
|
201
|
+
for (let i = 0; i < parts.length; i++) {
|
|
202
|
+
if (i > 0) re += '\\/';
|
|
203
|
+
if (parts[i] === '**') {
|
|
204
|
+
re += '.*';
|
|
205
|
+
} else {
|
|
206
|
+
let seg = parts[i]
|
|
207
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
208
|
+
.replace(/\?/g, '.')
|
|
209
|
+
.replace(/\*/g, '[^/]*');
|
|
210
|
+
re += seg;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// '**' at end should match everything under that directory
|
|
214
|
+
if (pattern.endsWith('/**')) {
|
|
215
|
+
re += '(?:\\/.*)?';
|
|
216
|
+
}
|
|
217
|
+
return new RegExp('^' + re + '$', 'i');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function applyExclusions(files, excludeConfig, projectRoot) {
|
|
221
|
+
if (!excludeConfig) return files;
|
|
222
|
+
|
|
223
|
+
const patternRes = (excludeConfig.patterns || []).map(p => globToRegex(p));
|
|
224
|
+
const pathPrefixes = (excludeConfig.paths || []).map(p => {
|
|
225
|
+
const abs = require('path').resolve(projectRoot, p).replace(/\\/g, '/');
|
|
226
|
+
return abs;
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
return files.filter(f => {
|
|
230
|
+
const rp = f.relative || f.path;
|
|
231
|
+
for (const re of patternRes) {
|
|
232
|
+
if (re.test(rp)) return false;
|
|
233
|
+
}
|
|
234
|
+
const full = (f.full || require('path').resolve(projectRoot, f.path || '')).replace(/\\/g, '/');
|
|
235
|
+
for (const prefix of pathPrefixes) {
|
|
236
|
+
if (full.startsWith(prefix)) return false;
|
|
237
|
+
}
|
|
238
|
+
return true;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---- Config loading ----
|
|
243
|
+
function loadConfig(projectRoot) {
|
|
244
|
+
const configPath = path.join(projectRoot, '.sandtable.json');
|
|
245
|
+
if (fs.existsSync(configPath)) {
|
|
246
|
+
try {
|
|
247
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
248
|
+
} catch { /* ignore */ }
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- File walking ----
|
|
254
|
+
function walkMd(dir) {
|
|
255
|
+
const results = [];
|
|
256
|
+
if (!fs.existsSync(dir)) return results;
|
|
257
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
258
|
+
for (const entry of entries) {
|
|
259
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
260
|
+
const full = path.join(dir, entry.name);
|
|
261
|
+
if (entry.isDirectory()) {
|
|
262
|
+
results.push(...walkMd(full));
|
|
263
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
264
|
+
results.push(full);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return results;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---- SUMMARY block extraction ----
|
|
271
|
+
function extractSummary(content) {
|
|
272
|
+
const match = content.match(/<!--\s*SUMMARY\s*([\s\S]*?)\s*-->/);
|
|
273
|
+
if (!match) return null;
|
|
274
|
+
try {
|
|
275
|
+
return JSON.parse(match[1]);
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---- Time info extraction (hour precision) ----
|
|
282
|
+
function extractTimeInfo(content, filename, fileModified) {
|
|
283
|
+
const mtimeISO = new Date(fileModified).toISOString();
|
|
284
|
+
|
|
285
|
+
// 1. Date from filename: YYYY-MM-DD format → set to noon local
|
|
286
|
+
const dateInName = filename.match(/(\d{4}-\d{2}-\d{2})/);
|
|
287
|
+
if (dateInName) return { date: dateInName[1] + 'T12:00:00+08:00', dateSource: 'filename' };
|
|
288
|
+
|
|
289
|
+
// 2. Date from content (first YYYY-MM-DD occurrence) → set to noon local
|
|
290
|
+
const dateInContent = content.match(/(\d{4}-\d{2}-\d{2})/);
|
|
291
|
+
if (dateInContent) return { date: dateInContent[1] + 'T12:00:00+08:00', dateSource: 'content' };
|
|
292
|
+
|
|
293
|
+
// 3. Fall back to file modification time (already has hour precision)
|
|
294
|
+
return { date: mtimeISO, dateSource: 'mtime' };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---- Pattern-based classification ----
|
|
298
|
+
function classifyByPattern(filename) {
|
|
299
|
+
for (const pattern of PATTERNS) {
|
|
300
|
+
if (pattern.test(filename)) {
|
|
301
|
+
return pattern.classify();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return { kind: 'secondary', elementType: 'unknown', category: 'unknown' };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---- Main scan ----
|
|
308
|
+
function scan(projectRoot) {
|
|
309
|
+
const config = loadConfig(projectRoot);
|
|
310
|
+
const mdFiles = walkMd(projectRoot);
|
|
311
|
+
|
|
312
|
+
// Build file objects with relative paths for exclusion checking
|
|
313
|
+
let fileEntries = mdFiles.map(f => ({
|
|
314
|
+
full: f,
|
|
315
|
+
relative: path.relative(projectRoot, f).replace(/\\/g, '/'),
|
|
316
|
+
}));
|
|
317
|
+
|
|
318
|
+
// Apply exclusion list (禁读清单)
|
|
319
|
+
fileEntries = applyExclusions(fileEntries, config && config.exclude, projectRoot);
|
|
320
|
+
|
|
321
|
+
const files = fileEntries.map(fe => {
|
|
322
|
+
const f = fe.full;
|
|
323
|
+
const relative = fe.relative;
|
|
324
|
+
const filename = path.basename(f);
|
|
325
|
+
const stat = fs.statSync(f);
|
|
326
|
+
const content = fs.readFileSync(f, 'utf-8');
|
|
327
|
+
const summary = extractSummary(content);
|
|
328
|
+
|
|
329
|
+
// Step 1: classify by filename pattern
|
|
330
|
+
let { kind, elementType, category } = classifyByPattern(filename);
|
|
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;
|
|
345
|
+
|
|
346
|
+
// Step 4: SUMMARY block as fallback correction (only if auto-detection returned 'unknown')
|
|
347
|
+
const corrected = applySummaryCorrection(summary, { kind, elementType, category, _pathRefined: meta._pathRefined });
|
|
348
|
+
kind = corrected.kind;
|
|
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
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Extract time info
|
|
364
|
+
const timeInfo = extractTimeInfo(content, filename, stat.mtime.toISOString());
|
|
365
|
+
|
|
366
|
+
// H1 title
|
|
367
|
+
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
368
|
+
const title = titleMatch ? titleMatch[1] : filename.replace(/\.md$/, '');
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
path: relative,
|
|
372
|
+
filename,
|
|
373
|
+
category,
|
|
374
|
+
kind, // v2: 'primary' | 'secondary'
|
|
375
|
+
elementType, // v2: phase|milestone|subtask|conclusion|decision|journal|...
|
|
376
|
+
type: elementType, // backward compat alias
|
|
377
|
+
title,
|
|
378
|
+
size: stat.size,
|
|
379
|
+
modified: stat.mtime.toISOString(),
|
|
380
|
+
date: timeInfo.date, // v2: best-guess date
|
|
381
|
+
dateSource: timeInfo.dateSource,// v2: how date was determined
|
|
382
|
+
hasSummary: summary !== null,
|
|
383
|
+
summary: summary || null,
|
|
384
|
+
};
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// Collect unique element types (v2 additions)
|
|
388
|
+
const primaryCount = files.filter(f => f.kind === 'primary').length;
|
|
389
|
+
const secondaryCount = files.filter(f => f.kind === 'secondary').length;
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
scannedAt: new Date().toISOString(),
|
|
393
|
+
projectRoot,
|
|
394
|
+
totalFiles: files.length,
|
|
395
|
+
primaryCount, // v2
|
|
396
|
+
secondaryCount, // v2
|
|
397
|
+
filesWithSummary: files.filter(f => f.hasSummary).length,
|
|
398
|
+
categories: [...new Set(files.map(f => f.category))],
|
|
399
|
+
elementTypes: [...new Set(files.map(f => f.elementType))], // v2
|
|
400
|
+
files,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
module.exports = {
|
|
405
|
+
scan, walkMd, classifyByPattern, refineByPath, refineByContent,
|
|
406
|
+
extractSummary, extractTimeInfo, loadConfig,
|
|
407
|
+
applySummaryCorrection, applyExclusions, normalizeCategory, DISPLAY_CATEGORIES,
|
|
408
|
+
PRIMARY_CATEGORIES,
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
if (require.main === module) {
|
|
412
|
+
const root = process.argv[2] || process.cwd();
|
|
413
|
+
const result = scan(root);
|
|
414
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
415
|
+
}
|
|
@@ -1,32 +1,33 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_schema": "
|
|
3
|
-
"_comment": "sandtable
|
|
2
|
+
"_schema": "4.0",
|
|
3
|
+
"_comment": "sandtable v1.0 配置文件。将此文件复制到项目根目录并改名为 .sandtable.json,按需编辑。",
|
|
4
4
|
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
"docContract": {
|
|
6
|
+
"schema": "sandtable-v1",
|
|
7
|
+
"categories": [
|
|
8
|
+
{ "id": "roadmap", "label": "路线图与进度", "order": 1, "paths": ["docs/plan/**", "docs/specs/*roadmap*", "docs/specs/*phase*", "docs/specs/*bug-collect*"] },
|
|
9
|
+
{ "id": "decision", "label": "决策记录", "order": 2, "paths": ["docs/specs/decisions/**"] },
|
|
10
|
+
{ "id": "spec", "label": "业务规格", "order": 3, "paths": ["docs/specs/**"] },
|
|
11
|
+
{ "id": "convention", "label": "协作纪律", "order": 4, "paths": ["docs/skills/**", "docs/agents/**", "docs/conventions/**", "AGENTS.md", "CLAUDE.md"] },
|
|
12
|
+
{ "id": "ops", "label": "运维与基建", "order": 5, "paths": ["docs/runbooks/**"] },
|
|
13
|
+
{ "id": "archive", "label": "历史档案", "order": 6, "paths": ["docs/plans/**", "docs/journal/**", "docs/archive/**"], "defaultHidden": true }
|
|
14
|
+
],
|
|
15
|
+
"meta": [
|
|
16
|
+
"docs/*-reference.md",
|
|
17
|
+
"docs/*-glossary.md"
|
|
18
|
+
]
|
|
11
19
|
},
|
|
12
20
|
|
|
13
|
-
"
|
|
14
|
-
"_comment": "classifyBy: 'status' 按状态分类, 'date' 按日期分类。",
|
|
15
|
-
"classifyBy": "status",
|
|
16
|
-
"pastStatus": ["completed", "cancelled"],
|
|
17
|
-
"currentStatus": ["in_progress", "pending"],
|
|
18
|
-
"futureStatus": []
|
|
19
|
-
},
|
|
21
|
+
"progressSources": [],
|
|
20
22
|
|
|
21
|
-
"
|
|
22
|
-
"_comment": "
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"maxBriefLength": 200
|
|
23
|
+
"events": {
|
|
24
|
+
"_comment": "事件流配置。gitLog: 是否从 git log 生成代码变更事件。maxGitLogEntries: 最多拉取多少条。",
|
|
25
|
+
"gitLog": true,
|
|
26
|
+
"maxGitLogEntries": 100
|
|
26
27
|
},
|
|
27
28
|
|
|
28
29
|
"exclude": {
|
|
29
|
-
"_comment": "
|
|
30
|
+
"_comment": "排除清单。patterns 用 glob 匹配文件路径。",
|
|
30
31
|
"patterns": [
|
|
31
32
|
"**/.env*",
|
|
32
33
|
"**/credentials*",
|
|
@@ -42,10 +43,7 @@
|
|
|
42
43
|
"paths": []
|
|
43
44
|
},
|
|
44
45
|
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"gitLog": true,
|
|
48
|
-
"maxGitLogEntries": 100,
|
|
49
|
-
"testBaseline": false
|
|
46
|
+
"display": {
|
|
47
|
+
"maxBriefLength": 200
|
|
50
48
|
}
|
|
51
49
|
}
|