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.
@@ -1,17 +1,53 @@
1
1
  #!/bin/sh
2
2
  # Install sandtable git hooks into the current repo
3
- # Usage: sh harness/install-hooks.sh [project-root]
3
+ # Usage: sh harness/install-hooks.sh [project-root] [--force]
4
+
5
+ ROOT=""
6
+ FORCE=0
7
+ for arg in "$@"; do
8
+ case "$arg" in
9
+ --force) FORCE=1 ;;
10
+ *) ROOT="$arg" ;;
11
+ esac
12
+ done
13
+ ROOT="${ROOT:-$(pwd)}"
4
14
 
5
- ROOT="${1:-$(pwd)}"
6
15
  HOOKS_DIR="$ROOT/.git/hooks"
16
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
7
17
 
8
18
  if [ ! -d "$HOOKS_DIR" ]; then
9
19
  echo "Error: not a git repository (no .git/hooks)"
10
20
  exit 1
11
21
  fi
12
22
 
13
- cp harness/post-commit "$HOOKS_DIR/post-commit.sandtable"
14
- cp harness/post-merge "$HOOKS_DIR/post-merge.sandtable"
23
+ # ---- Conflict detection: check for existing non-sandtable hooks ----
24
+ CONFLICTS=""
25
+ for hook in post-commit post-merge; do
26
+ HOOK_PATH="$HOOKS_DIR/$hook"
27
+ if [ -f "$HOOK_PATH" ] && ! grep -q "sandtable" "$HOOK_PATH" 2>/dev/null; then
28
+ CONFLICTS="$CONFLICTS $hook"
29
+ fi
30
+ done
31
+
32
+ if [ -n "$CONFLICTS" ] && [ "$FORCE" != "1" ]; then
33
+ echo "Error: 已有非 sandtable hook 存在:$CONFLICTS"
34
+ echo ""
35
+ echo "sandtable 不会覆盖已有 hook,以免破坏其他工具链。选项:"
36
+ echo " 1. 手动合并: 在现有 hook 末尾添加以下行:"
37
+ echo " sh .git/hooks/<hook>.sandtable"
38
+ echo " 2. 强制覆盖: sh $(basename "$0") --force"
39
+ echo ""
40
+ echo "已有 hook 内容预览:"
41
+ for hook in $CONFLICTS; do
42
+ echo " --- $hook ---"
43
+ sed 's/^/ | /' "$HOOKS_DIR/$hook"
44
+ echo " -------------"
45
+ done
46
+ exit 1
47
+ fi
48
+
49
+ cp "$SCRIPT_DIR/post-commit" "$HOOKS_DIR/post-commit.sandtable"
50
+ cp "$SCRIPT_DIR/post-merge" "$HOOKS_DIR/post-merge.sandtable"
15
51
  chmod +x "$HOOKS_DIR/post-commit.sandtable"
16
52
  chmod +x "$HOOKS_DIR/post-merge.sandtable"
17
53
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sandtable",
3
- "version": "0.3.1",
3
+ "version": "1.0.1",
4
4
  "description": "AI 编程项目可视化指挥面板 — 双视图 dashboard,多源数据融合",
5
5
  "main": "src/cli/sandtable.js",
6
6
  "bin": {
package/server.js CHANGED
@@ -5,6 +5,26 @@ const path = require('path');
5
5
  const ROOT = path.resolve(__dirname);
6
6
  const PORT = parseInt(process.env.PORT || process.argv[2], 10) || 5199;
7
7
 
8
+ // Parse --host and --token from CLI args (safe by default: 127.0.0.1)
9
+ let HOST = '127.0.0.1';
10
+ let TOKEN = '';
11
+ for (let i = 0; i < process.argv.length; i++) {
12
+ if (process.argv[i] === '--host' && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')) {
13
+ HOST = process.argv[i + 1];
14
+ i++;
15
+ } else if (process.argv[i] === '--token') {
16
+ if (process.argv[i + 1] && !process.argv[i + 1].startsWith('--')) {
17
+ TOKEN = process.argv[i + 1];
18
+ i++;
19
+ } else {
20
+ TOKEN = require('crypto').randomBytes(16).toString('hex');
21
+ }
22
+ }
23
+ }
24
+ if ((HOST === '0.0.0.0' || HOST === '::') && !TOKEN) {
25
+ TOKEN = require('crypto').randomBytes(16).toString('hex');
26
+ }
27
+
8
28
  const MIME = {
9
29
  '.html': 'text/html; charset=utf-8',
10
30
  '.css': 'text/css; charset=utf-8',
@@ -16,21 +36,51 @@ const MIME = {
16
36
  '.svg': 'image/svg+xml',
17
37
  };
18
38
 
19
- // Only serve from these two directories — project sources are not exposed
39
+ // Only serve from these directories — project sources are not exposed
20
40
  const ALLOWED_PREFIXES = [
21
41
  path.join(ROOT, 'dashboard'),
22
42
  path.join(ROOT, 'data'),
43
+ path.join(ROOT, 'docs'),
23
44
  ];
24
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
+
25
52
  function isAllowed(filePath) {
26
53
  if (!filePath.startsWith(ROOT)) return false;
27
54
  for (const prefix of ALLOWED_PREFIXES) {
28
55
  if (filePath.startsWith(prefix)) return true;
29
56
  }
57
+ // Allow specific root-level documentation files
58
+ if (ROOT_ALLOWED.has(path.relative(ROOT, filePath))) return true;
30
59
  return false;
31
60
  }
32
61
 
33
62
  http.createServer((req, res) => {
63
+ // Token authentication (when binding to public interface)
64
+ if (TOKEN) {
65
+ let qIdx = req.url.indexOf('?');
66
+ let hasToken = false;
67
+ if (qIdx !== -1) {
68
+ let qs = req.url.substring(qIdx + 1);
69
+ let pairs = qs.split('&');
70
+ for (let pi = 0; pi < pairs.length; pi++) {
71
+ let kv = pairs[pi].split('=');
72
+ if (decodeURIComponent(kv[0]) === 'token' && kv[1] === TOKEN) {
73
+ hasToken = true;
74
+ break;
75
+ }
76
+ }
77
+ }
78
+ if (!hasToken) {
79
+ res.writeHead(401, { 'Content-Type': 'text/plain; charset=utf-8' });
80
+ return res.end('401 Unauthorized — 请在 URL 后添加 ?token=' + TOKEN);
81
+ }
82
+ }
83
+
34
84
  let url = req.url === '/' ? '/dashboard/dashboard.html' : req.url;
35
85
  url = url.replace(/^\/(css|js)\//, '/dashboard/$1/');
36
86
 
@@ -54,7 +104,8 @@ http.createServer((req, res) => {
54
104
  res.writeHead(500, { 'Content-Type': 'text/plain' });
55
105
  res.end('500: ' + e.message);
56
106
  }
57
- }).listen(PORT, () => {
58
- console.log('Sandtable: http://localhost:' + PORT);
107
+ }).listen(PORT, HOST, () => {
108
+ console.log('Sandtable: http://' + HOST + ':' + PORT);
109
+ if (TOKEN) console.log('Token: ' + TOKEN + ' (访问需携带 ?token=' + TOKEN + ')');
59
110
  console.log('Project:', ROOT);
60
111
  });
@@ -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) {