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.
@@ -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.0",
3
- "_comment": "sandtable v3 配置文件。将此文件复制到项目根目录并改名为 .sandtable.json,按需编辑。",
2
+ "_schema": "4.0",
3
+ "_comment": "sandtable v1.0 配置文件。将此文件复制到项目根目录并改名为 .sandtable.json,按需编辑。",
4
4
 
5
- "paths": {
6
- "_comment": "覆盖默认目录到类别的映射。不配则用内置规则自动检测。",
7
- "plan": "docs/plan/",
8
- "archive": "docs/journal/",
9
- "convention": "docs/conventions/",
10
- "spec": "docs/specs/"
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
- "timeRules": {
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
- "display": {
22
- "_comment": "secondaryTypesDefaultOff: true = 次要元素 checkbox 默认不勾选。conventionsManualOnly: true = conventions bar 默认显示'待配置'占位。",
23
- "secondaryTypesDefaultOff": true,
24
- "conventionsManualOnly": true,
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": "排除清单(禁读清单)。patterns 用 glob 匹配文件路径,paths 按绝对路径前缀匹配。命中任一规则则跳过不扫描。",
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
- "events": {
46
- "_comment": "事件流配置。gitLog: 是否从 git log 生成代码变更事件。maxGitLogEntries: 最多拉取多少条。testBaseline: 是否解析测试 JSON(P2)。",
47
- "gitLog": true,
48
- "maxGitLogEntries": 100,
49
- "testBaseline": false
46
+ "display": {
47
+ "maxBriefLength": 200
50
48
  }
51
49
  }