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 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.23
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
@@ -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', {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scene-capability-engine",
3
- "version": "3.3.25",
3
+ "version": "3.3.26",
4
4
  "description": "SCE (Scene Capability Engine) - A CLI tool and npm package for spec-driven development with AI coding assistants.",
5
5
  "main": "index.js",
6
6
  "bin": {