scene-capability-engine 3.3.24 → 3.3.26
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/CHANGELOG.md +10 -0
- package/bin/scene-capability-engine.js +12 -0
- package/docs/command-reference.md +37 -1
- package/lib/commands/session.js +27 -0
- package/lib/commands/spec-related.js +70 -0
- package/lib/commands/studio.js +66 -12
- package/lib/commands/timeline.js +287 -0
- package/lib/runtime/project-timeline.js +598 -0
- package/lib/spec/related-specs.js +260 -0
- package/package.json +1 -1
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const { spawnSync } = require('child_process');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const minimatchModule = require('minimatch');
|
|
5
|
+
|
|
6
|
+
const minimatch = typeof minimatchModule === 'function'
|
|
7
|
+
? minimatchModule
|
|
8
|
+
: (minimatchModule && typeof minimatchModule.minimatch === 'function'
|
|
9
|
+
? minimatchModule.minimatch
|
|
10
|
+
: () => false);
|
|
11
|
+
|
|
12
|
+
const TIMELINE_SCHEMA_VERSION = '1.0';
|
|
13
|
+
const TIMELINE_CONFIG_RELATIVE_PATH = path.join('.sce', 'config', 'timeline.json');
|
|
14
|
+
const TIMELINE_DIR = path.join('.sce', 'timeline');
|
|
15
|
+
const TIMELINE_INDEX_FILE = 'index.json';
|
|
16
|
+
const TIMELINE_SNAPSHOTS_DIR = 'snapshots';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_TIMELINE_CONFIG = Object.freeze({
|
|
19
|
+
enabled: true,
|
|
20
|
+
auto_interval_minutes: 30,
|
|
21
|
+
max_entries: 120,
|
|
22
|
+
exclude_paths: [
|
|
23
|
+
'.git/**',
|
|
24
|
+
'node_modules/**',
|
|
25
|
+
'.sce/timeline/**',
|
|
26
|
+
'coverage/**',
|
|
27
|
+
'dist/**',
|
|
28
|
+
'build/**',
|
|
29
|
+
'.next/**',
|
|
30
|
+
'tmp/**',
|
|
31
|
+
'temp/**'
|
|
32
|
+
]
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function nowIso() {
|
|
36
|
+
return new Date().toISOString();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizePosix(relativePath) {
|
|
40
|
+
return `${relativePath || ''}`.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\//, '');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeBoolean(value, fallback = false) {
|
|
44
|
+
if (typeof value === 'boolean') {
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
const normalized = `${value || ''}`.trim().toLowerCase();
|
|
48
|
+
if (!normalized) {
|
|
49
|
+
return fallback;
|
|
50
|
+
}
|
|
51
|
+
if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
return fallback;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizePositiveInteger(value, fallback, max = Number.MAX_SAFE_INTEGER) {
|
|
61
|
+
const parsed = Number.parseInt(`${value}`, 10);
|
|
62
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
63
|
+
return fallback;
|
|
64
|
+
}
|
|
65
|
+
return Math.min(parsed, max);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function safeSnapshotId(value) {
|
|
69
|
+
return `${value || ''}`
|
|
70
|
+
.trim()
|
|
71
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
72
|
+
.replace(/^-+|-+$/g, '')
|
|
73
|
+
.slice(0, 96);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function createSnapshotId(prefix = 'ts') {
|
|
77
|
+
const now = new Date();
|
|
78
|
+
const yyyy = now.getUTCFullYear();
|
|
79
|
+
const mm = `${now.getUTCMonth() + 1}`.padStart(2, '0');
|
|
80
|
+
const dd = `${now.getUTCDate()}`.padStart(2, '0');
|
|
81
|
+
const hh = `${now.getUTCHours()}`.padStart(2, '0');
|
|
82
|
+
const mi = `${now.getUTCMinutes()}`.padStart(2, '0');
|
|
83
|
+
const ss = `${now.getUTCSeconds()}`.padStart(2, '0');
|
|
84
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
85
|
+
return `${prefix}-${yyyy}${mm}${dd}-${hh}${mi}${ss}-${rand}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
class ProjectTimelineStore {
|
|
89
|
+
constructor(projectPath = process.cwd(), fileSystem = fs) {
|
|
90
|
+
this._projectPath = projectPath;
|
|
91
|
+
this._fileSystem = fileSystem;
|
|
92
|
+
this._timelineDir = path.join(projectPath, TIMELINE_DIR);
|
|
93
|
+
this._indexPath = path.join(this._timelineDir, TIMELINE_INDEX_FILE);
|
|
94
|
+
this._snapshotsDir = path.join(this._timelineDir, TIMELINE_SNAPSHOTS_DIR);
|
|
95
|
+
this._configPath = path.join(projectPath, TIMELINE_CONFIG_RELATIVE_PATH);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async getConfig() {
|
|
99
|
+
let filePayload = {};
|
|
100
|
+
if (await this._fileSystem.pathExists(this._configPath)) {
|
|
101
|
+
try {
|
|
102
|
+
filePayload = await this._fileSystem.readJson(this._configPath);
|
|
103
|
+
} catch (_error) {
|
|
104
|
+
filePayload = {};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const merged = {
|
|
109
|
+
...DEFAULT_TIMELINE_CONFIG,
|
|
110
|
+
...(filePayload && typeof filePayload === 'object' ? filePayload : {})
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
merged.enabled = normalizeBoolean(merged.enabled, DEFAULT_TIMELINE_CONFIG.enabled);
|
|
114
|
+
merged.auto_interval_minutes = normalizePositiveInteger(
|
|
115
|
+
merged.auto_interval_minutes,
|
|
116
|
+
DEFAULT_TIMELINE_CONFIG.auto_interval_minutes,
|
|
117
|
+
24 * 60
|
|
118
|
+
);
|
|
119
|
+
merged.max_entries = normalizePositiveInteger(
|
|
120
|
+
merged.max_entries,
|
|
121
|
+
DEFAULT_TIMELINE_CONFIG.max_entries,
|
|
122
|
+
10000
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const rawExcludes = Array.isArray(merged.exclude_paths)
|
|
126
|
+
? merged.exclude_paths
|
|
127
|
+
: DEFAULT_TIMELINE_CONFIG.exclude_paths;
|
|
128
|
+
merged.exclude_paths = Array.from(new Set(
|
|
129
|
+
rawExcludes
|
|
130
|
+
.map((item) => normalizePosix(item))
|
|
131
|
+
.filter(Boolean)
|
|
132
|
+
));
|
|
133
|
+
|
|
134
|
+
return merged;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async updateConfig(patch = {}) {
|
|
138
|
+
const current = await this.getConfig();
|
|
139
|
+
const next = {
|
|
140
|
+
...current,
|
|
141
|
+
...(patch && typeof patch === 'object' ? patch : {})
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
next.enabled = normalizeBoolean(next.enabled, current.enabled);
|
|
145
|
+
next.auto_interval_minutes = normalizePositiveInteger(next.auto_interval_minutes, current.auto_interval_minutes, 24 * 60);
|
|
146
|
+
next.max_entries = normalizePositiveInteger(next.max_entries, current.max_entries, 10000);
|
|
147
|
+
next.exclude_paths = Array.from(new Set(
|
|
148
|
+
(Array.isArray(next.exclude_paths) ? next.exclude_paths : current.exclude_paths)
|
|
149
|
+
.map((item) => normalizePosix(item))
|
|
150
|
+
.filter(Boolean)
|
|
151
|
+
));
|
|
152
|
+
|
|
153
|
+
await this._fileSystem.ensureDir(path.dirname(this._configPath));
|
|
154
|
+
await this._fileSystem.writeJson(this._configPath, next, { spaces: 2 });
|
|
155
|
+
return next;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async maybeAutoSnapshot(options = {}) {
|
|
159
|
+
const config = await this.getConfig();
|
|
160
|
+
if (!config.enabled) {
|
|
161
|
+
return {
|
|
162
|
+
mode: 'timeline-auto',
|
|
163
|
+
success: true,
|
|
164
|
+
created: false,
|
|
165
|
+
reason: 'disabled'
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const index = await this._readIndex();
|
|
170
|
+
const latest = index.snapshots[0] || null;
|
|
171
|
+
const intervalMinutes = normalizePositiveInteger(options.intervalMinutes, config.auto_interval_minutes, 24 * 60);
|
|
172
|
+
|
|
173
|
+
if (latest && latest.created_at) {
|
|
174
|
+
const elapsedMs = Date.now() - Date.parse(latest.created_at);
|
|
175
|
+
if (Number.isFinite(elapsedMs) && elapsedMs < intervalMinutes * 60 * 1000) {
|
|
176
|
+
return {
|
|
177
|
+
mode: 'timeline-auto',
|
|
178
|
+
success: true,
|
|
179
|
+
created: false,
|
|
180
|
+
reason: 'interval-not-reached',
|
|
181
|
+
latest_snapshot_id: latest.snapshot_id,
|
|
182
|
+
minutes_remaining: Math.max(0, Math.ceil((intervalMinutes * 60 * 1000 - elapsedMs) / 60000))
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const created = await this.saveSnapshot({
|
|
188
|
+
trigger: 'auto',
|
|
189
|
+
event: options.event || 'auto.tick',
|
|
190
|
+
summary: options.summary || 'auto timeline checkpoint'
|
|
191
|
+
});
|
|
192
|
+
return {
|
|
193
|
+
mode: 'timeline-auto',
|
|
194
|
+
success: true,
|
|
195
|
+
created: true,
|
|
196
|
+
snapshot: created
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async saveSnapshot(options = {}) {
|
|
201
|
+
const config = await this.getConfig();
|
|
202
|
+
if (!config.enabled && options.force !== true) {
|
|
203
|
+
return {
|
|
204
|
+
mode: 'timeline-save',
|
|
205
|
+
success: true,
|
|
206
|
+
created: false,
|
|
207
|
+
reason: 'disabled'
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await this._fileSystem.ensureDir(this._snapshotsDir);
|
|
212
|
+
|
|
213
|
+
const requestedId = safeSnapshotId(options.snapshotId);
|
|
214
|
+
const snapshotId = requestedId || createSnapshotId('tl');
|
|
215
|
+
const createdAt = nowIso();
|
|
216
|
+
|
|
217
|
+
const snapshotRoot = path.join(this._snapshotsDir, snapshotId);
|
|
218
|
+
const workspaceRoot = path.join(snapshotRoot, 'workspace');
|
|
219
|
+
await this._fileSystem.ensureDir(workspaceRoot);
|
|
220
|
+
|
|
221
|
+
const excludePatterns = this._buildExcludePatterns(config.exclude_paths);
|
|
222
|
+
const files = await this._collectWorkspaceFiles(excludePatterns);
|
|
223
|
+
|
|
224
|
+
let totalBytes = 0;
|
|
225
|
+
for (const relativePath of files) {
|
|
226
|
+
const sourcePath = path.join(this._projectPath, relativePath);
|
|
227
|
+
const targetPath = path.join(workspaceRoot, relativePath);
|
|
228
|
+
await this._fileSystem.ensureDir(path.dirname(targetPath));
|
|
229
|
+
await this._fileSystem.copyFile(sourcePath, targetPath);
|
|
230
|
+
const stat = await this._fileSystem.stat(targetPath);
|
|
231
|
+
totalBytes += Number(stat.size || 0);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const metadata = {
|
|
235
|
+
schema_version: TIMELINE_SCHEMA_VERSION,
|
|
236
|
+
snapshot_id: snapshotId,
|
|
237
|
+
created_at: createdAt,
|
|
238
|
+
trigger: `${options.trigger || 'manual'}`,
|
|
239
|
+
event: `${options.event || 'manual.save'}`,
|
|
240
|
+
summary: `${options.summary || ''}`.trim(),
|
|
241
|
+
session_id: `${options.sessionId || ''}`.trim() || null,
|
|
242
|
+
scene_id: `${options.sceneId || ''}`.trim() || null,
|
|
243
|
+
command: `${options.command || ''}`.trim() || null,
|
|
244
|
+
file_count: files.length,
|
|
245
|
+
total_bytes: totalBytes,
|
|
246
|
+
git: this._readGitStatus(),
|
|
247
|
+
files_manifest: 'files.json'
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
await this._fileSystem.writeJson(path.join(snapshotRoot, 'snapshot.json'), metadata, { spaces: 2 });
|
|
251
|
+
await this._fileSystem.writeJson(path.join(snapshotRoot, 'files.json'), {
|
|
252
|
+
snapshot_id: snapshotId,
|
|
253
|
+
file_count: files.length,
|
|
254
|
+
files
|
|
255
|
+
}, { spaces: 2 });
|
|
256
|
+
|
|
257
|
+
const index = await this._readIndex();
|
|
258
|
+
const entry = {
|
|
259
|
+
snapshot_id: snapshotId,
|
|
260
|
+
created_at: createdAt,
|
|
261
|
+
trigger: metadata.trigger,
|
|
262
|
+
event: metadata.event,
|
|
263
|
+
summary: metadata.summary,
|
|
264
|
+
scene_id: metadata.scene_id,
|
|
265
|
+
session_id: metadata.session_id,
|
|
266
|
+
command: metadata.command,
|
|
267
|
+
file_count: files.length,
|
|
268
|
+
total_bytes: totalBytes,
|
|
269
|
+
path: this._toRelativePosix(snapshotRoot),
|
|
270
|
+
git: metadata.git
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
index.snapshots = Array.isArray(index.snapshots) ? index.snapshots : [];
|
|
274
|
+
index.snapshots.unshift(entry);
|
|
275
|
+
|
|
276
|
+
const limit = normalizePositiveInteger(config.max_entries, DEFAULT_TIMELINE_CONFIG.max_entries, 10000);
|
|
277
|
+
if (index.snapshots.length > limit) {
|
|
278
|
+
const removed = index.snapshots.splice(limit);
|
|
279
|
+
for (const obsolete of removed) {
|
|
280
|
+
const obsoletePath = path.join(this._projectPath, normalizePosix(obsolete.path || ''));
|
|
281
|
+
try {
|
|
282
|
+
await this._fileSystem.remove(obsoletePath);
|
|
283
|
+
} catch (_error) {
|
|
284
|
+
// best effort cleanup
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
await this._writeIndex(index);
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
...entry,
|
|
293
|
+
snapshot_root: snapshotRoot,
|
|
294
|
+
workspace_root: workspaceRoot
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async listSnapshots(options = {}) {
|
|
299
|
+
const index = await this._readIndex();
|
|
300
|
+
const trigger = `${options.trigger || ''}`.trim();
|
|
301
|
+
const limit = normalizePositiveInteger(options.limit, 20, 1000);
|
|
302
|
+
|
|
303
|
+
let snapshots = Array.isArray(index.snapshots) ? [...index.snapshots] : [];
|
|
304
|
+
if (trigger) {
|
|
305
|
+
snapshots = snapshots.filter((item) => `${item.trigger || ''}`.trim() === trigger);
|
|
306
|
+
}
|
|
307
|
+
snapshots = snapshots.slice(0, limit);
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
mode: 'timeline-list',
|
|
311
|
+
success: true,
|
|
312
|
+
total: snapshots.length,
|
|
313
|
+
snapshots
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async getSnapshot(snapshotId) {
|
|
318
|
+
const normalizedId = safeSnapshotId(snapshotId);
|
|
319
|
+
if (!normalizedId) {
|
|
320
|
+
throw new Error('snapshotId is required');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const index = await this._readIndex();
|
|
324
|
+
const entry = index.snapshots.find((item) => item.snapshot_id === normalizedId);
|
|
325
|
+
if (!entry) {
|
|
326
|
+
throw new Error(`Timeline snapshot not found: ${normalizedId}`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const snapshotRoot = path.join(this._projectPath, normalizePosix(entry.path || ''));
|
|
330
|
+
const metadataPath = path.join(snapshotRoot, 'snapshot.json');
|
|
331
|
+
const filesPath = path.join(snapshotRoot, 'files.json');
|
|
332
|
+
|
|
333
|
+
let metadata = null;
|
|
334
|
+
let files = null;
|
|
335
|
+
try {
|
|
336
|
+
metadata = await this._fileSystem.readJson(metadataPath);
|
|
337
|
+
} catch (_error) {
|
|
338
|
+
metadata = null;
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
files = await this._fileSystem.readJson(filesPath);
|
|
342
|
+
} catch (_error) {
|
|
343
|
+
files = null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
mode: 'timeline-show',
|
|
348
|
+
success: true,
|
|
349
|
+
snapshot: entry,
|
|
350
|
+
metadata,
|
|
351
|
+
files
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async restoreSnapshot(snapshotId, options = {}) {
|
|
356
|
+
const normalizedId = safeSnapshotId(snapshotId);
|
|
357
|
+
if (!normalizedId) {
|
|
358
|
+
throw new Error('snapshotId is required');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const config = await this.getConfig();
|
|
362
|
+
const index = await this._readIndex();
|
|
363
|
+
const entry = index.snapshots.find((item) => item.snapshot_id === normalizedId);
|
|
364
|
+
if (!entry) {
|
|
365
|
+
throw new Error(`Timeline snapshot not found: ${normalizedId}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const snapshotRoot = path.join(this._projectPath, normalizePosix(entry.path || ''));
|
|
369
|
+
const workspaceRoot = path.join(snapshotRoot, 'workspace');
|
|
370
|
+
if (!await this._fileSystem.pathExists(workspaceRoot)) {
|
|
371
|
+
throw new Error(`Timeline snapshot workspace missing: ${normalizedId}`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (options.preSave !== false) {
|
|
375
|
+
await this.saveSnapshot({
|
|
376
|
+
trigger: 'manual',
|
|
377
|
+
event: 'restore.pre-save',
|
|
378
|
+
summary: `pre-restore checkpoint before ${normalizedId}`,
|
|
379
|
+
force: true
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const excludePatterns = this._buildExcludePatterns(config.exclude_paths);
|
|
384
|
+
const snapshotFiles = await this._collectFilesFromDirectory(workspaceRoot, excludePatterns, true);
|
|
385
|
+
|
|
386
|
+
for (const relativePath of snapshotFiles) {
|
|
387
|
+
const sourcePath = path.join(workspaceRoot, relativePath);
|
|
388
|
+
const targetPath = path.join(this._projectPath, relativePath);
|
|
389
|
+
await this._fileSystem.ensureDir(path.dirname(targetPath));
|
|
390
|
+
await this._fileSystem.copyFile(sourcePath, targetPath);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (options.prune === true) {
|
|
394
|
+
const currentFiles = await this._collectWorkspaceFiles(excludePatterns);
|
|
395
|
+
const snapshotSet = new Set(snapshotFiles);
|
|
396
|
+
for (const relativePath of currentFiles) {
|
|
397
|
+
if (!snapshotSet.has(relativePath)) {
|
|
398
|
+
await this._fileSystem.remove(path.join(this._projectPath, relativePath));
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const restored = await this.saveSnapshot({
|
|
404
|
+
trigger: 'restore',
|
|
405
|
+
event: 'restore.completed',
|
|
406
|
+
summary: `restored from ${normalizedId}`,
|
|
407
|
+
force: true
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
mode: 'timeline-restore',
|
|
412
|
+
success: true,
|
|
413
|
+
restored_from: normalizedId,
|
|
414
|
+
restored_snapshot: restored,
|
|
415
|
+
pruned: options.prune === true
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
_buildExcludePatterns(configPatterns = []) {
|
|
420
|
+
const defaults = DEFAULT_TIMELINE_CONFIG.exclude_paths;
|
|
421
|
+
return Array.from(new Set([
|
|
422
|
+
...defaults,
|
|
423
|
+
...(Array.isArray(configPatterns) ? configPatterns : [])
|
|
424
|
+
].map((item) => normalizePosix(item)).filter(Boolean)));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
_isExcluded(relativePath, patterns = []) {
|
|
428
|
+
const normalized = normalizePosix(relativePath);
|
|
429
|
+
if (!normalized) {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
for (const pattern of patterns) {
|
|
433
|
+
if (minimatch(normalized, pattern, { dot: true })) {
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async _collectWorkspaceFiles(excludePatterns = []) {
|
|
441
|
+
return this._collectFilesFromDirectory(this._projectPath, excludePatterns, false);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async _collectFilesFromDirectory(rootPath, excludePatterns = [], relativeMode = false) {
|
|
445
|
+
const files = [];
|
|
446
|
+
const queue = [''];
|
|
447
|
+
|
|
448
|
+
while (queue.length > 0) {
|
|
449
|
+
const currentRelative = queue.shift();
|
|
450
|
+
const currentAbsolute = currentRelative
|
|
451
|
+
? path.join(rootPath, currentRelative)
|
|
452
|
+
: rootPath;
|
|
453
|
+
|
|
454
|
+
let entries = [];
|
|
455
|
+
try {
|
|
456
|
+
entries = await this._fileSystem.readdir(currentAbsolute, { withFileTypes: true });
|
|
457
|
+
} catch (_error) {
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
for (const entry of entries) {
|
|
462
|
+
const childRelative = currentRelative
|
|
463
|
+
? normalizePosix(path.join(currentRelative, entry.name))
|
|
464
|
+
: normalizePosix(entry.name);
|
|
465
|
+
|
|
466
|
+
if (this._isExcluded(childRelative, excludePatterns)) {
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (entry.isDirectory()) {
|
|
471
|
+
queue.push(childRelative);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (!entry.isFile()) {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
files.push(relativeMode ? childRelative : childRelative);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
files.sort();
|
|
484
|
+
return files;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
_toRelativePosix(absolutePath) {
|
|
488
|
+
return path.relative(this._projectPath, absolutePath).replace(/\\/g, '/');
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
_readGitStatus() {
|
|
492
|
+
const branch = this._spawnGit(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
493
|
+
const head = this._spawnGit(['rev-parse', 'HEAD']);
|
|
494
|
+
const porcelain = this._spawnGit(['status', '--porcelain']);
|
|
495
|
+
const dirtyFiles = porcelain.ok
|
|
496
|
+
? porcelain.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
|
|
497
|
+
: [];
|
|
498
|
+
return {
|
|
499
|
+
branch: branch.ok ? branch.stdout : null,
|
|
500
|
+
head: head.ok ? head.stdout : null,
|
|
501
|
+
dirty: dirtyFiles.length > 0,
|
|
502
|
+
dirty_count: dirtyFiles.length,
|
|
503
|
+
dirty_files: dirtyFiles.slice(0, 100)
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
_spawnGit(args = []) {
|
|
508
|
+
try {
|
|
509
|
+
const result = spawnSync('git', args, {
|
|
510
|
+
cwd: this._projectPath,
|
|
511
|
+
encoding: 'utf8',
|
|
512
|
+
windowsHide: true
|
|
513
|
+
});
|
|
514
|
+
if (result.status !== 0) {
|
|
515
|
+
return { ok: false, stdout: '', stderr: `${result.stderr || ''}`.trim() };
|
|
516
|
+
}
|
|
517
|
+
return { ok: true, stdout: `${result.stdout || ''}`.trim(), stderr: '' };
|
|
518
|
+
} catch (error) {
|
|
519
|
+
return { ok: false, stdout: '', stderr: error.message };
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async _readIndex() {
|
|
524
|
+
if (!await this._fileSystem.pathExists(this._indexPath)) {
|
|
525
|
+
return {
|
|
526
|
+
schema_version: TIMELINE_SCHEMA_VERSION,
|
|
527
|
+
updated_at: nowIso(),
|
|
528
|
+
snapshots: []
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
try {
|
|
533
|
+
const payload = await this._fileSystem.readJson(this._indexPath);
|
|
534
|
+
return {
|
|
535
|
+
schema_version: payload && payload.schema_version ? payload.schema_version : TIMELINE_SCHEMA_VERSION,
|
|
536
|
+
updated_at: payload && payload.updated_at ? payload.updated_at : nowIso(),
|
|
537
|
+
snapshots: Array.isArray(payload && payload.snapshots) ? payload.snapshots : []
|
|
538
|
+
};
|
|
539
|
+
} catch (_error) {
|
|
540
|
+
return {
|
|
541
|
+
schema_version: TIMELINE_SCHEMA_VERSION,
|
|
542
|
+
updated_at: nowIso(),
|
|
543
|
+
snapshots: []
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async _writeIndex(index = {}) {
|
|
549
|
+
const payload = {
|
|
550
|
+
schema_version: TIMELINE_SCHEMA_VERSION,
|
|
551
|
+
updated_at: nowIso(),
|
|
552
|
+
snapshots: Array.isArray(index.snapshots) ? index.snapshots : []
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
await this._fileSystem.ensureDir(this._timelineDir);
|
|
556
|
+
await this._fileSystem.writeJson(this._indexPath, payload, { spaces: 2 });
|
|
557
|
+
return payload;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function captureTimelineCheckpoint(options = {}, dependencies = {}) {
|
|
562
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
563
|
+
const store = dependencies.timelineStore || new ProjectTimelineStore(projectPath, dependencies.fileSystem || fs);
|
|
564
|
+
|
|
565
|
+
try {
|
|
566
|
+
if (options.auto !== false) {
|
|
567
|
+
await store.maybeAutoSnapshot({
|
|
568
|
+
event: options.event || 'checkpoint.auto',
|
|
569
|
+
summary: options.autoSummary || options.summary || ''
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
return await store.saveSnapshot({
|
|
573
|
+
trigger: options.trigger || 'key-event',
|
|
574
|
+
event: options.event || 'checkpoint.event',
|
|
575
|
+
summary: options.summary || '',
|
|
576
|
+
command: options.command || '',
|
|
577
|
+
sessionId: options.sessionId,
|
|
578
|
+
sceneId: options.sceneId
|
|
579
|
+
});
|
|
580
|
+
} catch (error) {
|
|
581
|
+
return {
|
|
582
|
+
mode: 'timeline-checkpoint',
|
|
583
|
+
success: false,
|
|
584
|
+
error: error.message
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
module.exports = {
|
|
590
|
+
ProjectTimelineStore,
|
|
591
|
+
TIMELINE_SCHEMA_VERSION,
|
|
592
|
+
TIMELINE_CONFIG_RELATIVE_PATH,
|
|
593
|
+
TIMELINE_DIR,
|
|
594
|
+
TIMELINE_INDEX_FILE,
|
|
595
|
+
TIMELINE_SNAPSHOTS_DIR,
|
|
596
|
+
DEFAULT_TIMELINE_CONFIG,
|
|
597
|
+
captureTimelineCheckpoint
|
|
598
|
+
};
|