scene-capability-engine 3.3.25 → 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 +6 -0
- package/bin/scene-capability-engine.js +2 -0
- package/docs/command-reference.md +32 -1
- package/lib/commands/session.js +27 -0
- package/lib/commands/studio.js +32 -9
- package/lib/commands/timeline.js +287 -0
- package/lib/runtime/project-timeline.js +598 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
14
14
|
- New script gate:
|
|
15
15
|
- `node scripts/errorbook-registry-health-gate.js`
|
|
16
16
|
- supports strict mode via `SCE_REGISTRY_HEALTH_STRICT=1`
|
|
17
|
+
- Local project timeline snapshot system:
|
|
18
|
+
- new command group: `sce timeline ...`
|
|
19
|
+
- supports manual save/list/show/restore/config and `timeline push` (pre-push checkpoint + git push)
|
|
20
|
+
- snapshots are retained under `.sce/timeline/snapshots/` with configurable retention policy
|
|
21
|
+
- key-stage checkpoint integration for `studio` and `session` command flows
|
|
17
22
|
|
|
18
23
|
### Changed
|
|
19
24
|
- `prepublishOnly` now runs `gate:errorbook-registry-health` in advisory mode before `errorbook-release` gate.
|
|
@@ -46,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
46
51
|
- new command: `sce spec-related` (alias route: `sce spec related`)
|
|
47
52
|
- supports query/scene/spec-seeded lookup and relevance ranking
|
|
48
53
|
- `sce studio plan` now auto-loads related historical specs into job metadata (`source.related_specs`)
|
|
54
|
+
- SCE now captures timeline checkpoints by default on `studio`/`session` key operations, and performs interval auto-check in the same checkpoint pipeline to reduce local history-loss risk.
|
|
49
55
|
|
|
50
56
|
## [3.3.23] - 2026-02-27
|
|
51
57
|
|
|
@@ -21,6 +21,7 @@ const { registerSpecPipelineCommand } = require('../lib/commands/spec-pipeline')
|
|
|
21
21
|
const { registerSpecGateCommand } = require('../lib/commands/spec-gate');
|
|
22
22
|
const { registerSpecDomainCommand } = require('../lib/commands/spec-domain');
|
|
23
23
|
const { registerSpecRelatedCommand } = require('../lib/commands/spec-related');
|
|
24
|
+
const { registerTimelineCommands } = require('../lib/commands/timeline');
|
|
24
25
|
const { registerValueCommands } = require('../lib/commands/value');
|
|
25
26
|
const VersionChecker = require('../lib/version/version-checker');
|
|
26
27
|
const {
|
|
@@ -820,6 +821,7 @@ registerCollabCommands(program);
|
|
|
820
821
|
// Universal steering and runtime session commands
|
|
821
822
|
registerSteeringCommands(program);
|
|
822
823
|
registerSessionCommands(program);
|
|
824
|
+
registerTimelineCommands(program);
|
|
823
825
|
|
|
824
826
|
// Autonomous control commands
|
|
825
827
|
const { registerAutoCommands } = require('../lib/commands/auto');
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Quick reference for all `sce` commands
|
|
4
4
|
|
|
5
|
-
**Version**: 3.3.
|
|
5
|
+
**Version**: 3.3.25
|
|
6
6
|
**Last Updated**: 2026-02-27
|
|
7
7
|
|
|
8
8
|
---
|
|
@@ -96,6 +96,37 @@ Spec session governance:
|
|
|
96
96
|
- `.sce/specs/<spec>/custom/scene-spec.md`
|
|
97
97
|
- `.sce/specs/<spec>/custom/problem-domain-chain.json`
|
|
98
98
|
|
|
99
|
+
### Timeline Snapshots
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
# Manual checkpoint
|
|
103
|
+
sce timeline save --summary "before large refactor" --json
|
|
104
|
+
|
|
105
|
+
# Auto interval checkpoint tick (skips when interval is not reached)
|
|
106
|
+
sce timeline auto --json
|
|
107
|
+
|
|
108
|
+
# List and inspect snapshots
|
|
109
|
+
sce timeline list --limit 20 --json
|
|
110
|
+
sce timeline show <snapshot-id> --json
|
|
111
|
+
|
|
112
|
+
# Restore workspace to a snapshot (safe mode keeps extra files)
|
|
113
|
+
sce timeline restore <snapshot-id> --json
|
|
114
|
+
|
|
115
|
+
# Hard restore (also prune files not in snapshot)
|
|
116
|
+
sce timeline restore <snapshot-id> --prune --json
|
|
117
|
+
|
|
118
|
+
# Update timeline policy
|
|
119
|
+
sce timeline config --enabled true --interval 30 --max-entries 120 --json
|
|
120
|
+
|
|
121
|
+
# Push with pre-push snapshot
|
|
122
|
+
sce timeline push origin main
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Timeline policy:
|
|
126
|
+
- default enabled with local retention under `.sce/timeline/snapshots/`
|
|
127
|
+
- stage/key-event checkpoints are automatically captured for `studio` and `session` commands
|
|
128
|
+
- interval auto-checkpoints are integrated in the same flow via timeline checkpoint capture
|
|
129
|
+
|
|
99
130
|
### Value Metrics
|
|
100
131
|
|
|
101
132
|
```bash
|
package/lib/commands/session.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const chalk = require('chalk');
|
|
2
2
|
const { SessionStore } = require('../runtime/session-store');
|
|
3
|
+
const { captureTimelineCheckpoint } = require('../runtime/project-timeline');
|
|
3
4
|
|
|
4
5
|
function registerSessionCommands(program) {
|
|
5
6
|
const session = program
|
|
@@ -15,6 +16,14 @@ function registerSessionCommands(program) {
|
|
|
15
16
|
.option('--json', 'Output as JSON')
|
|
16
17
|
.action(async (objective, options) => {
|
|
17
18
|
try {
|
|
19
|
+
await captureTimelineCheckpoint({
|
|
20
|
+
trigger: 'key-event',
|
|
21
|
+
event: 'session.start',
|
|
22
|
+
summary: `session start | tool=${options.tool || 'generic'} | objective=${objective || ''}`.trim(),
|
|
23
|
+
command: 'sce session start'
|
|
24
|
+
}, {
|
|
25
|
+
projectPath: process.cwd()
|
|
26
|
+
});
|
|
18
27
|
const store = new SessionStore(process.cwd());
|
|
19
28
|
const created = await store.startSession({
|
|
20
29
|
tool: options.tool,
|
|
@@ -35,6 +44,15 @@ function registerSessionCommands(program) {
|
|
|
35
44
|
.option('--json', 'Output as JSON')
|
|
36
45
|
.action(async (sessionRef, options) => {
|
|
37
46
|
try {
|
|
47
|
+
await captureTimelineCheckpoint({
|
|
48
|
+
trigger: 'key-event',
|
|
49
|
+
event: 'session.resume',
|
|
50
|
+
summary: `session resume | ref=${sessionRef || 'latest'} | status=${options.status || 'active'}`,
|
|
51
|
+
command: 'sce session resume',
|
|
52
|
+
sessionId: sessionRef || 'latest'
|
|
53
|
+
}, {
|
|
54
|
+
projectPath: process.cwd()
|
|
55
|
+
});
|
|
38
56
|
const store = new SessionStore(process.cwd());
|
|
39
57
|
const resumed = await store.resumeSession(sessionRef || 'latest', {
|
|
40
58
|
status: options.status,
|
|
@@ -54,6 +72,15 @@ function registerSessionCommands(program) {
|
|
|
54
72
|
.option('--json', 'Output as JSON')
|
|
55
73
|
.action(async (sessionRef, options) => {
|
|
56
74
|
try {
|
|
75
|
+
await captureTimelineCheckpoint({
|
|
76
|
+
trigger: 'key-event',
|
|
77
|
+
event: 'session.snapshot',
|
|
78
|
+
summary: `session snapshot | ref=${sessionRef || 'latest'} | status=${options.status || 'inherit'}`,
|
|
79
|
+
command: 'sce session snapshot',
|
|
80
|
+
sessionId: sessionRef || 'latest'
|
|
81
|
+
}, {
|
|
82
|
+
projectPath: process.cwd()
|
|
83
|
+
});
|
|
57
84
|
const store = new SessionStore(process.cwd());
|
|
58
85
|
const payload = _parsePayload(options.payload);
|
|
59
86
|
const snapshotted = await store.snapshotSession(sessionRef || 'latest', {
|
package/lib/commands/studio.js
CHANGED
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
ensureSpecDomainArtifacts
|
|
10
10
|
} = require('../spec/domain-modeling');
|
|
11
11
|
const { findRelatedSpecs } = require('../spec/related-specs');
|
|
12
|
+
const { captureTimelineCheckpoint } = require('../runtime/project-timeline');
|
|
12
13
|
|
|
13
14
|
const STUDIO_JOB_API_VERSION = 'sce.studio.job/v0.1';
|
|
14
15
|
const STAGE_ORDER = ['plan', 'generate', 'apply', 'verify', 'release'];
|
|
@@ -1636,8 +1637,30 @@ async function runStudioEventsCommand(options = {}, dependencies = {}) {
|
|
|
1636
1637
|
return payload;
|
|
1637
1638
|
}
|
|
1638
1639
|
|
|
1639
|
-
async function runStudioCommand(handler, options) {
|
|
1640
|
+
async function runStudioCommand(handler, options, stageName = '') {
|
|
1640
1641
|
try {
|
|
1642
|
+
const stage = normalizeString(stageName) || 'unknown';
|
|
1643
|
+
const sceneId = normalizeString(options && options.scene);
|
|
1644
|
+
const summaryGoal = normalizeString(options && options.goal);
|
|
1645
|
+
const fromChat = normalizeString(options && options.fromChat);
|
|
1646
|
+
const summaryParts = [
|
|
1647
|
+
'studio',
|
|
1648
|
+
stage,
|
|
1649
|
+
sceneId ? `scene=${sceneId}` : '',
|
|
1650
|
+
summaryGoal ? `goal=${summaryGoal}` : '',
|
|
1651
|
+
fromChat ? `chat=${fromChat}` : ''
|
|
1652
|
+
].filter(Boolean);
|
|
1653
|
+
|
|
1654
|
+
await captureTimelineCheckpoint({
|
|
1655
|
+
trigger: 'key-event',
|
|
1656
|
+
event: `studio.${stage}`,
|
|
1657
|
+
summary: summaryParts.join(' | '),
|
|
1658
|
+
command: `sce studio ${stage}`.trim(),
|
|
1659
|
+
sceneId
|
|
1660
|
+
}, {
|
|
1661
|
+
projectPath: process.cwd()
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1641
1664
|
await handler(options);
|
|
1642
1665
|
} catch (error) {
|
|
1643
1666
|
console.error(chalk.red(`Studio command failed: ${error.message}`));
|
|
@@ -1660,7 +1683,7 @@ function registerStudioCommands(program) {
|
|
|
1660
1683
|
.option('--target <target>', 'Target integration profile', 'default')
|
|
1661
1684
|
.option('--job <job-id>', 'Reuse an explicit studio job id')
|
|
1662
1685
|
.option('--json', 'Print machine-readable JSON output')
|
|
1663
|
-
.action(async (options) => runStudioCommand(runStudioPlanCommand, options));
|
|
1686
|
+
.action(async (options) => runStudioCommand(runStudioPlanCommand, options, 'plan'));
|
|
1664
1687
|
|
|
1665
1688
|
studio
|
|
1666
1689
|
.command('generate')
|
|
@@ -1670,7 +1693,7 @@ function registerStudioCommands(program) {
|
|
|
1670
1693
|
.option('--patch-bundle <id>', 'Explicit patch bundle id')
|
|
1671
1694
|
.option('--job <job-id>', 'Studio job id (defaults to latest)')
|
|
1672
1695
|
.option('--json', 'Print machine-readable JSON output')
|
|
1673
|
-
.action(async (options) => runStudioCommand(runStudioGenerateCommand, options));
|
|
1696
|
+
.action(async (options) => runStudioCommand(runStudioGenerateCommand, options, 'generate'));
|
|
1674
1697
|
|
|
1675
1698
|
studio
|
|
1676
1699
|
.command('apply')
|
|
@@ -1680,7 +1703,7 @@ function registerStudioCommands(program) {
|
|
|
1680
1703
|
.option('--require-auth', 'Require authorization even when policy is advisory')
|
|
1681
1704
|
.option('--job <job-id>', 'Studio job id (defaults to latest)')
|
|
1682
1705
|
.option('--json', 'Print machine-readable JSON output')
|
|
1683
|
-
.action(async (options) => runStudioCommand(runStudioApplyCommand, options));
|
|
1706
|
+
.action(async (options) => runStudioCommand(runStudioApplyCommand, options, 'apply'));
|
|
1684
1707
|
|
|
1685
1708
|
studio
|
|
1686
1709
|
.command('verify')
|
|
@@ -1688,7 +1711,7 @@ function registerStudioCommands(program) {
|
|
|
1688
1711
|
.option('--profile <profile>', 'Verification profile', 'standard')
|
|
1689
1712
|
.option('--job <job-id>', 'Studio job id (defaults to latest)')
|
|
1690
1713
|
.option('--json', 'Print machine-readable JSON output')
|
|
1691
|
-
.action(async (options) => runStudioCommand(runStudioVerifyCommand, options));
|
|
1714
|
+
.action(async (options) => runStudioCommand(runStudioVerifyCommand, options, 'verify'));
|
|
1692
1715
|
|
|
1693
1716
|
studio
|
|
1694
1717
|
.command('release')
|
|
@@ -1700,14 +1723,14 @@ function registerStudioCommands(program) {
|
|
|
1700
1723
|
.option('--release-ref <ref>', 'Explicit release reference/tag')
|
|
1701
1724
|
.option('--job <job-id>', 'Studio job id (defaults to latest)')
|
|
1702
1725
|
.option('--json', 'Print machine-readable JSON output')
|
|
1703
|
-
.action(async (options) => runStudioCommand(runStudioReleaseCommand, options));
|
|
1726
|
+
.action(async (options) => runStudioCommand(runStudioReleaseCommand, options, 'release'));
|
|
1704
1727
|
|
|
1705
1728
|
studio
|
|
1706
1729
|
.command('resume')
|
|
1707
1730
|
.description('Inspect current studio job and next action')
|
|
1708
1731
|
.option('--job <job-id>', 'Studio job id (defaults to latest)')
|
|
1709
1732
|
.option('--json', 'Print machine-readable JSON output')
|
|
1710
|
-
.action(async (options) => runStudioCommand(runStudioResumeCommand, options));
|
|
1733
|
+
.action(async (options) => runStudioCommand(runStudioResumeCommand, options, 'resume'));
|
|
1711
1734
|
|
|
1712
1735
|
studio
|
|
1713
1736
|
.command('events')
|
|
@@ -1715,7 +1738,7 @@ function registerStudioCommands(program) {
|
|
|
1715
1738
|
.option('--job <job-id>', 'Studio job id (defaults to latest)')
|
|
1716
1739
|
.option('--limit <number>', 'Maximum number of recent events to return', '50')
|
|
1717
1740
|
.option('--json', 'Print machine-readable JSON output')
|
|
1718
|
-
.action(async (options) => runStudioCommand(runStudioEventsCommand, options));
|
|
1741
|
+
.action(async (options) => runStudioCommand(runStudioEventsCommand, options, 'events'));
|
|
1719
1742
|
|
|
1720
1743
|
studio
|
|
1721
1744
|
.command('rollback')
|
|
@@ -1725,7 +1748,7 @@ function registerStudioCommands(program) {
|
|
|
1725
1748
|
.option('--auth-password <password>', 'Authorization password for protected rollback action')
|
|
1726
1749
|
.option('--require-auth', 'Require authorization even when policy is advisory')
|
|
1727
1750
|
.option('--json', 'Print machine-readable JSON output')
|
|
1728
|
-
.action(async (options) => runStudioCommand(runStudioRollbackCommand, options));
|
|
1751
|
+
.action(async (options) => runStudioCommand(runStudioRollbackCommand, options, 'rollback'));
|
|
1729
1752
|
}
|
|
1730
1753
|
|
|
1731
1754
|
module.exports = {
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
const { spawnSync } = require('child_process');
|
|
2
|
+
const chalk = require('chalk');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const {
|
|
5
|
+
ProjectTimelineStore,
|
|
6
|
+
captureTimelineCheckpoint
|
|
7
|
+
} = require('../runtime/project-timeline');
|
|
8
|
+
|
|
9
|
+
function normalizeText(value) {
|
|
10
|
+
if (typeof value !== 'string') {
|
|
11
|
+
return '';
|
|
12
|
+
}
|
|
13
|
+
return value.trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizePositiveInteger(value, fallback, max = 10000) {
|
|
17
|
+
const parsed = Number.parseInt(`${value}`, 10);
|
|
18
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
|
21
|
+
return Math.min(parsed, max);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeBoolean(value, fallback = false) {
|
|
25
|
+
if (typeof value === 'boolean') {
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
const normalized = normalizeText(`${value || ''}`).toLowerCase();
|
|
29
|
+
if (!normalized) {
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
32
|
+
if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return fallback;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createStore(dependencies = {}) {
|
|
42
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
43
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
44
|
+
return dependencies.timelineStore || new ProjectTimelineStore(projectPath, fileSystem);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function printPayload(payload, asJson = false, title = 'Timeline') {
|
|
48
|
+
if (asJson) {
|
|
49
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log(chalk.blue(title));
|
|
54
|
+
if (payload.mode) {
|
|
55
|
+
console.log(` Mode: ${payload.mode}`);
|
|
56
|
+
}
|
|
57
|
+
if (payload.snapshot && payload.snapshot.snapshot_id) {
|
|
58
|
+
console.log(` Snapshot: ${payload.snapshot.snapshot_id}`);
|
|
59
|
+
}
|
|
60
|
+
if (payload.snapshot_id) {
|
|
61
|
+
console.log(` Snapshot: ${payload.snapshot_id}`);
|
|
62
|
+
}
|
|
63
|
+
if (payload.restored_from) {
|
|
64
|
+
console.log(` Restored From: ${payload.restored_from}`);
|
|
65
|
+
}
|
|
66
|
+
if (typeof payload.total === 'number') {
|
|
67
|
+
console.log(` Total: ${payload.total}`);
|
|
68
|
+
}
|
|
69
|
+
if (Array.isArray(payload.snapshots)) {
|
|
70
|
+
for (const item of payload.snapshots) {
|
|
71
|
+
console.log(` - ${item.snapshot_id} | ${item.trigger} | ${item.created_at} | files=${item.file_count}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (payload.created === false && payload.reason) {
|
|
75
|
+
console.log(` Skipped: ${payload.reason}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function runTimelineSaveCommand(options = {}, dependencies = {}) {
|
|
80
|
+
const store = createStore(dependencies);
|
|
81
|
+
const payload = await store.saveSnapshot({
|
|
82
|
+
trigger: normalizeText(options.trigger) || 'manual',
|
|
83
|
+
event: normalizeText(options.event) || 'manual.save',
|
|
84
|
+
summary: normalizeText(options.summary),
|
|
85
|
+
command: normalizeText(options.command)
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const result = {
|
|
89
|
+
mode: 'timeline-save',
|
|
90
|
+
success: true,
|
|
91
|
+
snapshot: payload
|
|
92
|
+
};
|
|
93
|
+
printPayload(result, options.json, 'Timeline Save');
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function runTimelineAutoCommand(options = {}, dependencies = {}) {
|
|
98
|
+
const store = createStore(dependencies);
|
|
99
|
+
const payload = await store.maybeAutoSnapshot({
|
|
100
|
+
event: normalizeText(options.event) || 'auto.tick',
|
|
101
|
+
summary: normalizeText(options.summary),
|
|
102
|
+
intervalMinutes: options.interval
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
printPayload(payload, options.json, 'Timeline Auto');
|
|
106
|
+
return payload;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function runTimelineListCommand(options = {}, dependencies = {}) {
|
|
110
|
+
const store = createStore(dependencies);
|
|
111
|
+
const payload = await store.listSnapshots({
|
|
112
|
+
limit: normalizePositiveInteger(options.limit, 20, 2000),
|
|
113
|
+
trigger: normalizeText(options.trigger)
|
|
114
|
+
});
|
|
115
|
+
printPayload(payload, options.json, 'Timeline List');
|
|
116
|
+
return payload;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function runTimelineShowCommand(snapshotId, options = {}, dependencies = {}) {
|
|
120
|
+
const store = createStore(dependencies);
|
|
121
|
+
const payload = await store.getSnapshot(snapshotId);
|
|
122
|
+
printPayload(payload, options.json, 'Timeline Show');
|
|
123
|
+
return payload;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function runTimelineRestoreCommand(snapshotId, options = {}, dependencies = {}) {
|
|
127
|
+
const store = createStore(dependencies);
|
|
128
|
+
const payload = await store.restoreSnapshot(snapshotId, {
|
|
129
|
+
prune: normalizeBoolean(options.prune, false),
|
|
130
|
+
preSave: options.preSave !== false
|
|
131
|
+
});
|
|
132
|
+
printPayload(payload, options.json, 'Timeline Restore');
|
|
133
|
+
return payload;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function runTimelineConfigCommand(options = {}, dependencies = {}) {
|
|
137
|
+
const store = createStore(dependencies);
|
|
138
|
+
|
|
139
|
+
const patch = {};
|
|
140
|
+
if (typeof options.enabled !== 'undefined') {
|
|
141
|
+
patch.enabled = normalizeBoolean(options.enabled, true);
|
|
142
|
+
}
|
|
143
|
+
if (typeof options.interval !== 'undefined') {
|
|
144
|
+
patch.auto_interval_minutes = normalizePositiveInteger(options.interval, 30, 24 * 60);
|
|
145
|
+
}
|
|
146
|
+
if (typeof options.maxEntries !== 'undefined') {
|
|
147
|
+
patch.max_entries = normalizePositiveInteger(options.maxEntries, 120, 10000);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const hasPatch = Object.keys(patch).length > 0;
|
|
151
|
+
const payload = hasPatch
|
|
152
|
+
? await store.updateConfig(patch)
|
|
153
|
+
: await store.getConfig();
|
|
154
|
+
|
|
155
|
+
const result = {
|
|
156
|
+
mode: 'timeline-config',
|
|
157
|
+
success: true,
|
|
158
|
+
updated: hasPatch,
|
|
159
|
+
config: payload
|
|
160
|
+
};
|
|
161
|
+
printPayload(result, options.json, 'Timeline Config');
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function runTimelinePushCommand(gitArgs = [], options = {}, dependencies = {}) {
|
|
166
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
167
|
+
|
|
168
|
+
const checkpoint = await captureTimelineCheckpoint({
|
|
169
|
+
trigger: 'push',
|
|
170
|
+
event: 'git.push.preflight',
|
|
171
|
+
summary: normalizeText(options.summary) || 'pre-push timeline checkpoint',
|
|
172
|
+
command: `git push ${Array.isArray(gitArgs) ? gitArgs.join(' ') : ''}`.trim()
|
|
173
|
+
}, {
|
|
174
|
+
projectPath,
|
|
175
|
+
fileSystem: dependencies.fileSystem
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const result = spawnSync('git', ['push', ...(Array.isArray(gitArgs) ? gitArgs : [])], {
|
|
179
|
+
cwd: projectPath,
|
|
180
|
+
stdio: 'inherit',
|
|
181
|
+
windowsHide: true
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const statusCode = Number.isInteger(result.status) ? result.status : 1;
|
|
185
|
+
if (statusCode !== 0) {
|
|
186
|
+
const error = new Error(`git push failed with exit code ${statusCode}`);
|
|
187
|
+
error.exitCode = statusCode;
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const payload = {
|
|
192
|
+
mode: 'timeline-push',
|
|
193
|
+
success: true,
|
|
194
|
+
checkpoint,
|
|
195
|
+
command: `git push ${Array.isArray(gitArgs) ? gitArgs.join(' ') : ''}`.trim()
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
printPayload(payload, options.json, 'Timeline Push');
|
|
199
|
+
return payload;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function safeRun(handler, options = {}, ...args) {
|
|
203
|
+
try {
|
|
204
|
+
await handler(...args, options);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (options.json) {
|
|
207
|
+
console.log(JSON.stringify({ success: false, error: error.message }, null, 2));
|
|
208
|
+
} else {
|
|
209
|
+
console.error(chalk.red('Timeline command failed:'), error.message);
|
|
210
|
+
}
|
|
211
|
+
process.exitCode = error.exitCode || 1;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function registerTimelineCommands(program) {
|
|
216
|
+
const timeline = program
|
|
217
|
+
.command('timeline')
|
|
218
|
+
.description('Project local timeline snapshots (auto/key-event/manual/restore)');
|
|
219
|
+
|
|
220
|
+
timeline
|
|
221
|
+
.command('save')
|
|
222
|
+
.description('Create a manual timeline snapshot')
|
|
223
|
+
.option('--trigger <trigger>', 'Trigger label', 'manual')
|
|
224
|
+
.option('--event <event>', 'Event label', 'manual.save')
|
|
225
|
+
.option('--summary <text>', 'Summary for this checkpoint')
|
|
226
|
+
.option('--command <text>', 'Command context label')
|
|
227
|
+
.option('--json', 'Output as JSON')
|
|
228
|
+
.action(async (options) => safeRun(runTimelineSaveCommand, options));
|
|
229
|
+
|
|
230
|
+
timeline
|
|
231
|
+
.command('auto')
|
|
232
|
+
.description('Run interval-based auto timeline snapshot check')
|
|
233
|
+
.option('--interval <minutes>', 'Override auto interval minutes')
|
|
234
|
+
.option('--event <event>', 'Event label', 'auto.tick')
|
|
235
|
+
.option('--summary <text>', 'Summary for auto checkpoint')
|
|
236
|
+
.option('--json', 'Output as JSON')
|
|
237
|
+
.action(async (options) => safeRun(runTimelineAutoCommand, options));
|
|
238
|
+
|
|
239
|
+
timeline
|
|
240
|
+
.command('list')
|
|
241
|
+
.description('List timeline snapshots')
|
|
242
|
+
.option('--limit <n>', 'Maximum snapshots', '20')
|
|
243
|
+
.option('--trigger <trigger>', 'Filter by trigger')
|
|
244
|
+
.option('--json', 'Output as JSON')
|
|
245
|
+
.action(async (options) => safeRun(runTimelineListCommand, options));
|
|
246
|
+
|
|
247
|
+
timeline
|
|
248
|
+
.command('show <snapshotId>')
|
|
249
|
+
.description('Show one timeline snapshot')
|
|
250
|
+
.option('--json', 'Output as JSON')
|
|
251
|
+
.action(async (snapshotId, options) => safeRun(runTimelineShowCommand, options, snapshotId));
|
|
252
|
+
|
|
253
|
+
timeline
|
|
254
|
+
.command('restore <snapshotId>')
|
|
255
|
+
.description('Restore workspace from a timeline snapshot')
|
|
256
|
+
.option('--prune', 'Delete files not present in snapshot (dangerous)')
|
|
257
|
+
.option('--no-pre-save', 'Do not create a pre-restore snapshot')
|
|
258
|
+
.option('--json', 'Output as JSON')
|
|
259
|
+
.action(async (snapshotId, options) => safeRun(runTimelineRestoreCommand, options, snapshotId));
|
|
260
|
+
|
|
261
|
+
timeline
|
|
262
|
+
.command('config')
|
|
263
|
+
.description('Show/update timeline config')
|
|
264
|
+
.option('--enabled <boolean>', 'Enable timeline (true/false)')
|
|
265
|
+
.option('--interval <minutes>', 'Auto snapshot interval in minutes')
|
|
266
|
+
.option('--max-entries <n>', 'Maximum retained snapshots')
|
|
267
|
+
.option('--json', 'Output as JSON')
|
|
268
|
+
.action(async (options) => safeRun(runTimelineConfigCommand, options));
|
|
269
|
+
|
|
270
|
+
timeline
|
|
271
|
+
.command('push [gitArgs...]')
|
|
272
|
+
.description('Create a pre-push timeline snapshot, then run git push')
|
|
273
|
+
.option('--summary <text>', 'Summary for pre-push checkpoint')
|
|
274
|
+
.option('--json', 'Output as JSON')
|
|
275
|
+
.action(async (gitArgs, options) => safeRun(runTimelinePushCommand, options, gitArgs));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = {
|
|
279
|
+
runTimelineSaveCommand,
|
|
280
|
+
runTimelineAutoCommand,
|
|
281
|
+
runTimelineListCommand,
|
|
282
|
+
runTimelineShowCommand,
|
|
283
|
+
runTimelineRestoreCommand,
|
|
284
|
+
runTimelineConfigCommand,
|
|
285
|
+
runTimelinePushCommand,
|
|
286
|
+
registerTimelineCommands
|
|
287
|
+
};
|
|
@@ -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
|
+
};
|
package/package.json
CHANGED