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
package/harness/install-hooks.sh
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
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
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
|
|
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://
|
|
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
|
});
|
package/src/builder/build.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
-
const { 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
|
-
// ----
|
|
34
|
-
function
|
|
35
|
+
// ---- Tracks to Elements (converts parser output to timeline element format) ----
|
|
36
|
+
function tracksToElements(tracks) {
|
|
35
37
|
const elements = [];
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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:
|
|
45
|
+
name: phase.name,
|
|
46
|
+
status: phase.status,
|
|
189
47
|
timeGroup: 'current',
|
|
190
|
-
timeLabel: '',
|
|
191
|
-
date: null,
|
|
192
|
-
source: { file:
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
for (const f of
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
|
|
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' &&
|
|
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
|
-
|
|
858
|
-
|
|
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:
|
|
866
|
-
id:
|
|
867
|
-
name:
|
|
868
|
-
status:
|
|
869
|
-
owner:
|
|
870
|
-
subtasks: (
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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) {
|