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,302 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { globToRegex } = require('../contract/loader');
7
+
8
+ // ---- Parse a Markdown table anchored at a specific heading ----
9
+ function parseTableAtAnchor(content, anchor) {
10
+ const lines = content.split('\n');
11
+ let inTarget = false;
12
+ let header = null;
13
+ const rows = [];
14
+
15
+ for (let i = 0; i < lines.length; i++) {
16
+ const line = lines[i];
17
+
18
+ // Detect anchor heading (e.g. "## 5.2")
19
+ if (anchor && line.trim().startsWith(anchor)) {
20
+ inTarget = true;
21
+ continue;
22
+ }
23
+
24
+ // Stop at next same-level or higher heading
25
+ if (inTarget && /^##\s/.test(line) && !line.trim().startsWith(anchor)) {
26
+ if (rows.length > 0) break;
27
+ inTarget = false; // No table found in this section, stop looking
28
+ }
29
+ if (inTarget && /^#\s/.test(line) && rows.length > 0) break;
30
+
31
+ if (!inTarget) continue;
32
+
33
+ // Parse table rows
34
+ const trimmed = line.trim();
35
+ if (/^\|.+\|$/.test(trimmed) && !/^\|[-| ]+\|$/.test(trimmed)) {
36
+ if (!header) {
37
+ header = trimmed.split('|').map(c => c.trim()).filter(Boolean);
38
+ } else {
39
+ const cols = trimmed.split('|').map(c => c.trim()).filter(Boolean);
40
+ if (cols.length >= 2) {
41
+ const row = {};
42
+ header.forEach((h, idx) => { row[h] = cols[idx] || ''; });
43
+ rows.push(row);
44
+ }
45
+ }
46
+ } else if (!/^\|[-| ]+\|$/.test(trimmed)) {
47
+ // Non-table line — reset header if we're past the table
48
+ if (header && rows.length > 0) break;
49
+ header = null;
50
+ }
51
+ }
52
+
53
+ return { header, rows };
54
+ }
55
+
56
+ // ---- Normalize status string to standard enum ----
57
+ function normalizeStatus(rawStatus, statusMap) {
58
+ if (!rawStatus) return 'pending';
59
+
60
+ const trimmed = rawStatus.trim();
61
+
62
+ // Direct match in map
63
+ if (statusMap && statusMap[trimmed]) return statusMap[trimmed];
64
+
65
+ // Pattern match in map keys
66
+ if (statusMap) {
67
+ for (const [key, value] of Object.entries(statusMap)) {
68
+ if (trimmed.includes(key.replace(/[*_]/g, ''))) return value;
69
+ }
70
+ }
71
+
72
+ // Built-in fallback patterns
73
+ if (/✅|completed|complete|完成|完工/i.test(trimmed)) return 'completed';
74
+ if (/⏳|in.progress|进行中|next/i.test(trimmed)) return 'in_progress';
75
+ if (/🚫|blocked|阻塞/i.test(trimmed)) return 'blocked';
76
+ if (/❌|cancel|取消/i.test(trimmed)) return 'cancelled';
77
+
78
+ return 'pending';
79
+ }
80
+
81
+ // ---- Parse progress from a table source ----
82
+ function parseTableSource(source, projectRoot) {
83
+ const { file, anchor } = source;
84
+ const filePath = path.join(projectRoot, file);
85
+ if (!fs.existsSync(filePath)) return null;
86
+
87
+ const fileContent = fs.readFileSync(filePath, 'utf-8');
88
+ const { header, rows } = parseTableAtAnchor(fileContent, anchor || null);
89
+
90
+ if (!header || rows.length === 0) return null;
91
+
92
+ return { header, rows };
93
+ }
94
+
95
+ // ---- Parse progress from a statusLine source ----
96
+ function parseStatusLineSource(source, projectRoot) {
97
+ const results = [];
98
+
99
+ if (source.file) {
100
+ // Single file mode
101
+ const filePath = path.join(projectRoot, source.file);
102
+ if (!fs.existsSync(filePath)) return results;
103
+
104
+ const content = fs.readFileSync(filePath, 'utf-8');
105
+ const lines = content.split('\n');
106
+ const { regex, itemPattern, statusMap } = source.statusLinePattern || {};
107
+
108
+ if (!regex) return results;
109
+ const re = new RegExp(regex, 'g');
110
+
111
+ for (const line of lines) {
112
+ let match;
113
+ while ((match = re.exec(line)) !== null) {
114
+ const itemId = match[1] || '';
115
+ const statusRaw = match[2] || match[0];
116
+ results.push({
117
+ id: itemId,
118
+ status: normalizeStatus(statusRaw, statusMap)
119
+ });
120
+ }
121
+ }
122
+ } else if (source.glob) {
123
+ // Multi-file glob mode
124
+ const re = globToRegex(source.glob);
125
+
126
+ // Walk docs/ for matching files
127
+ function walk(dir) {
128
+ const out = [];
129
+ if (!fs.existsSync(dir)) return out;
130
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
131
+ for (const entry of entries) {
132
+ if (entry.name.startsWith('.')) continue;
133
+ const full = path.join(dir, entry.name);
134
+ if (entry.isDirectory()) {
135
+ out.push(...walk(full));
136
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
137
+ out.push(full);
138
+ }
139
+ }
140
+ return out;
141
+ }
142
+
143
+ const docsDir = path.join(projectRoot, 'docs');
144
+ const allFiles = walk(docsDir);
145
+
146
+ const { regex: sRegex, itemPattern: sItemPattern, statusMap } = source.statusLinePattern || {};
147
+ if (!sRegex) return results;
148
+
149
+ for (const filePath of allFiles) {
150
+ const rel = path.relative(projectRoot, filePath).replace(/\\/g, '/');
151
+ if (!re.test(rel)) continue;
152
+
153
+ const content = fs.readFileSync(filePath, 'utf-8');
154
+ const lines = content.split('\n');
155
+
156
+ // Extract date from filename
157
+ const dateMatch = path.basename(filePath).match(/(\d{4}-\d{2}-\d{2})/);
158
+ const date = dateMatch ? dateMatch[1] : null;
159
+
160
+ for (const line of lines) {
161
+ // Look for status line: "> **状态**:..." or "> **状态**: ..."
162
+ if (/^\s*>\s*\*\*状态\*\*[::]/.test(line)) {
163
+ const statusRaw = line.replace(/^\s*>\s*\*\*状态\*\*[::]\s*/, '').trim();
164
+ // Extract item ID from file content
165
+ let itemId = '';
166
+ if (sItemPattern) {
167
+ const itemRe = new RegExp(sItemPattern, 'g');
168
+ // Search context around the status line
169
+ const ctxStart = Math.max(0, lines.indexOf(line) - 5);
170
+ const ctxEnd = Math.min(lines.length, lines.indexOf(line) + 5);
171
+ const ctx = lines.slice(ctxStart, ctxEnd).join('\n');
172
+ let itemMatch;
173
+ while ((itemMatch = itemRe.exec(ctx)) !== null) {
174
+ // Prefer capture group if present, otherwise full match
175
+ itemId = itemMatch[1] || itemMatch[0];
176
+ break; // Take first match
177
+ }
178
+ }
179
+ if (!itemId && date) itemId = path.basename(filePath, '.md');
180
+
181
+ results.push({
182
+ id: itemId || path.basename(filePath, '.md'),
183
+ status: normalizeStatus(statusRaw, statusMap),
184
+ date: date,
185
+ source: rel
186
+ });
187
+ break; // One status per file
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ return results;
194
+ }
195
+
196
+ // ---- Build tracks from progressSources config ----
197
+ function buildTracks(progressSources, projectRoot) {
198
+ const sources = progressSources || [];
199
+ const tracks = [];
200
+
201
+ for (const ps of sources) {
202
+ const track = {
203
+ id: ps.track,
204
+ label: ps.label || ps.track,
205
+ source: ps.source,
206
+ phases: []
207
+ };
208
+
209
+ if (ps.source && ps.tableMap) {
210
+ // Table source (e.g. execution-plan §5.2)
211
+ const parsed = parseTableSource(ps.source, projectRoot);
212
+ if (parsed) {
213
+ const { rows } = parsed;
214
+ const { milestoneCol, taskCol, titleCol, actorCol, statusCol, batchCol } = ps.tableMap;
215
+ const statusMap = ps.statusNormalize || {};
216
+ const hierarchyRules = ps.hierarchyRules || {};
217
+
218
+ // Separate milestone rows from task rows
219
+ const parentPattern = hierarchyRules.parentPattern
220
+ ? new RegExp(hierarchyRules.parentPattern)
221
+ : null;
222
+ const childPattern = hierarchyRules.childPattern
223
+ ? new RegExp(hierarchyRules.childPattern)
224
+ : null;
225
+
226
+ const milestones = [];
227
+ let currentMilestone = null;
228
+
229
+ for (const row of rows) {
230
+ const id = (row[milestoneCol] || '').trim();
231
+ if (!id) continue;
232
+
233
+ if (parentPattern && parentPattern.test(id)) {
234
+ // This is a milestone row
235
+ currentMilestone = {
236
+ id: id,
237
+ name: (row[titleCol] || id).trim(),
238
+ status: normalizeStatus(row[statusCol], statusMap),
239
+ batch: batchCol ? (row[batchCol] || '').trim() : '',
240
+ tasks: []
241
+ };
242
+ milestones.push(currentMilestone);
243
+ } else if (childPattern && childPattern.test(id) && currentMilestone) {
244
+ // This is a task row — attach to current milestone
245
+ currentMilestone.tasks.push({
246
+ id: id,
247
+ title: (row[titleCol] || id).trim(),
248
+ status: normalizeStatus(row[statusCol], statusMap),
249
+ actor: actorCol ? (row[actorCol] || '').trim() : ''
250
+ });
251
+ }
252
+ }
253
+
254
+ // Sort milestones: extract number from ID for ordering
255
+ milestones.sort((a, b) => {
256
+ const na = parseInt(a.id.replace(/[^0-9]/g, '')) || 0;
257
+ const nb = parseInt(b.id.replace(/[^0-9]/g, '')) || 0;
258
+ return na - nb;
259
+ });
260
+
261
+ track.phases = milestones;
262
+ }
263
+ } else if (ps.source && ps.statusLinePattern) {
264
+ // StatusLine source (e.g. decision files)
265
+ const items = parseStatusLineSource(ps.source, projectRoot);
266
+ for (const item of items) {
267
+ track.phases.push({
268
+ id: item.id,
269
+ name: item.id,
270
+ status: item.status,
271
+ date: item.date,
272
+ source: item.source,
273
+ tasks: []
274
+ });
275
+ }
276
+ }
277
+
278
+ tracks.push(track);
279
+ }
280
+
281
+ // Extract current node from execution-plan content
282
+ let currentNode = null;
283
+ const epSource = sources.find(ps =>
284
+ ps.source && ps.source.file &&
285
+ ps.source.file.includes('execution-plan')
286
+ );
287
+ if (epSource) {
288
+ const fp = path.join(projectRoot, epSource.source.file);
289
+ if (fs.existsSync(fp)) {
290
+ const content = fs.readFileSync(fp, 'utf-8');
291
+ const cnMatch = content.match(/当前节点[::]\s*(T\d+)/);
292
+ if (cnMatch) currentNode = cnMatch[1];
293
+ }
294
+ }
295
+
296
+ return { tracks, currentNode };
297
+ }
298
+
299
+ module.exports = {
300
+ parseTableAtAnchor, normalizeStatus,
301
+ parseTableSource, parseStatusLineSource, buildTracks
302
+ };
@@ -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
- // ---- 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 视角:路线图(在做什么)/决策(为什么)/规格(长什么样)/协作(怎么协作)/运维(跑在哪)/档案(走过什么路)
49
- // Primary-only 类别 (roadmap, decision) 不出现在 filterType checkbox 中
50
- const DISPLAY_CATEGORIES = {
51
- roadmap: { label: '路线图与进度', elementTypes: ['phase', 'milestone', 'task', 'subtask', 'roadmap', 'backlog'] },
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']);
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
- // ---- Pattern-based classification ----
298
- function classifyByPattern(filename) {
299
- for (const pattern of PATTERNS) {
300
- if (pattern.test(filename)) {
301
- return pattern.classify();
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 { kind: 'secondary', elementType: 'unknown', category: 'unknown' };
119
+ return result;
305
120
  }
306
121
 
307
122
  // ---- Main scan ----
308
123
  function scan(projectRoot) {
309
- const config = loadConfig(projectRoot);
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 && config.exclude, projectRoot);
132
+ // Apply exclusion list
133
+ fileEntries = applyExclusions(fileEntries, config._exclude, projectRoot);
320
134
 
321
- const files = fileEntries.map(fe => {
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
- // 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;
146
+ // docContract-driven classification
147
+ const classification = classifyFile(relative, contract, summary);
345
148
 
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
- }
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
- return {
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, // v2: 'primary' | 'secondary'
375
- elementType, // v2: phase|milestone|subtask|conclusion|decision|journal|...
376
- type: elementType, // backward compat alias
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, // v2: best-guess date
381
- dateSource: timeInfo.dateSource,// v2: how date was determined
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, // v2
396
- secondaryCount, // v2
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))], // v2
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, classifyByPattern, refineByPath, refineByContent,
406
- extractSummary, extractTimeInfo, loadConfig,
407
- applySummaryCorrection, applyExclusions, normalizeCategory, DISPLAY_CATEGORIES,
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) {