scene-capability-engine 3.3.18 → 3.3.21

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.
@@ -0,0 +1,109 @@
1
+ const path = require('path');
2
+ const fs = require('fs-extra');
3
+ const { SessionStore } = require('./session-store');
4
+
5
+ function normalizeString(value) {
6
+ if (typeof value !== 'string') {
7
+ return '';
8
+ }
9
+ return value.trim();
10
+ }
11
+
12
+ async function loadLatestStudioJob(projectPath, fileSystem = fs) {
13
+ const latestPath = path.join(projectPath, '.sce', 'studio', 'latest-job.json');
14
+ if (!await fileSystem.pathExists(latestPath)) {
15
+ return null;
16
+ }
17
+
18
+ let latestPayload;
19
+ try {
20
+ latestPayload = await fileSystem.readJson(latestPath);
21
+ } catch (_error) {
22
+ return null;
23
+ }
24
+
25
+ const jobId = normalizeString(latestPayload && latestPayload.job_id);
26
+ if (!jobId) {
27
+ return null;
28
+ }
29
+
30
+ const jobPath = path.join(projectPath, '.sce', 'studio', 'jobs', `${jobId}.json`);
31
+ if (!await fileSystem.pathExists(jobPath)) {
32
+ return null;
33
+ }
34
+
35
+ try {
36
+ return await fileSystem.readJson(jobPath);
37
+ } catch (_error) {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ async function resolveSpecSceneBinding(options = {}, dependencies = {}) {
43
+ const projectPath = dependencies.projectPath || process.cwd();
44
+ const fileSystem = dependencies.fileSystem || fs;
45
+ const sessionStore = dependencies.sessionStore || new SessionStore(projectPath);
46
+ const explicitSceneId = normalizeString(options.sceneId || options.scene);
47
+ const allowNoScene = options.allowNoScene !== false;
48
+
49
+ if (explicitSceneId) {
50
+ const active = await sessionStore.getActiveSceneSession(explicitSceneId);
51
+ if (!active) {
52
+ throw new Error(`No active scene session for scene "${explicitSceneId}". Run "sce studio plan --scene ${explicitSceneId} --from-chat <session>" first.`);
53
+ }
54
+ return {
55
+ source: 'explicit',
56
+ scene_id: explicitSceneId,
57
+ scene_cycle: active.scene_cycle,
58
+ scene_session_id: active.session.session_id,
59
+ scene_session: active.session
60
+ };
61
+ }
62
+
63
+ const studioJob = await loadLatestStudioJob(projectPath, fileSystem);
64
+ const studioSceneId = normalizeString(studioJob && studioJob.scene && studioJob.scene.id);
65
+ const studioSceneSessionId = normalizeString(studioJob && studioJob.session && studioJob.session.scene_session_id);
66
+ if (studioSceneId && studioSceneSessionId) {
67
+ try {
68
+ const studioSession = await sessionStore.getSession(studioSceneSessionId);
69
+ if (studioSession && studioSession.status === 'active') {
70
+ return {
71
+ source: 'studio-latest',
72
+ scene_id: studioSceneId,
73
+ scene_cycle: studioSession.scene && studioSession.scene.cycle ? studioSession.scene.cycle : null,
74
+ scene_session_id: studioSession.session_id,
75
+ scene_session: studioSession
76
+ };
77
+ }
78
+ } catch (_error) {
79
+ // fall through to active scene scan
80
+ }
81
+ }
82
+
83
+ const activeScenes = await sessionStore.listActiveSceneSessions();
84
+ if (activeScenes.length === 1) {
85
+ return {
86
+ source: 'active-scene-auto',
87
+ scene_id: activeScenes[0].scene_id,
88
+ scene_cycle: activeScenes[0].scene_cycle,
89
+ scene_session_id: activeScenes[0].session.session_id,
90
+ scene_session: activeScenes[0].session
91
+ };
92
+ }
93
+
94
+ if (activeScenes.length > 1) {
95
+ const sceneIds = activeScenes.map((item) => item.scene_id).join(', ');
96
+ throw new Error(`Multiple active scene sessions detected (${sceneIds}). Use --scene <scene-id> to bind spec session explicitly.`);
97
+ }
98
+
99
+ if (allowNoScene) {
100
+ return null;
101
+ }
102
+
103
+ throw new Error('No active scene session found. Run "sce studio plan --scene <scene-id> --from-chat <session>" first.');
104
+ }
105
+
106
+ module.exports = {
107
+ resolveSpecSceneBinding,
108
+ loadLatestStudioJob
109
+ };
@@ -4,6 +4,9 @@ const { SteeringContract, normalizeToolName } = require('./steering-contract');
4
4
 
5
5
  const SESSION_SCHEMA_VERSION = '1.0';
6
6
  const SESSION_DIR = path.join('.sce', 'sessions');
7
+ const SESSION_GOVERNANCE_SCHEMA_VERSION = '1.0';
8
+ const SESSION_GOVERNANCE_DIR = path.join('.sce', 'session-governance');
9
+ const SESSION_SCENE_INDEX_FILE = 'scene-index.json';
7
10
 
8
11
  function toRelativePosix(workspaceRoot, absolutePath) {
9
12
  return path.relative(workspaceRoot, absolutePath).replace(/\\/g, '/');
@@ -25,10 +28,26 @@ function generateSessionId() {
25
28
  return `sess-${yyyy}${mm}${dd}-${hh}${mi}${ss}-${rand}`;
26
29
  }
27
30
 
31
+ function nowIso() {
32
+ return new Date().toISOString();
33
+ }
34
+
35
+ function safeSceneId(value) {
36
+ const normalized = `${value || ''}`.trim().replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
37
+ return normalized || 'scene';
38
+ }
39
+
40
+ function nextSnapshotId(session) {
41
+ const snapshots = Array.isArray(session && session.snapshots) ? session.snapshots : [];
42
+ return `snap-${snapshots.length + 1}`;
43
+ }
44
+
28
45
  class SessionStore {
29
46
  constructor(workspaceRoot, steeringContract = null) {
30
47
  this._workspaceRoot = workspaceRoot;
31
48
  this._sessionsDir = path.join(workspaceRoot, SESSION_DIR);
49
+ this._sessionGovernanceDir = path.join(workspaceRoot, SESSION_GOVERNANCE_DIR);
50
+ this._sceneIndexPath = path.join(this._sessionGovernanceDir, SESSION_SCENE_INDEX_FILE);
32
51
  this._steeringContract = steeringContract || new SteeringContract(workspaceRoot);
33
52
  }
34
53
 
@@ -38,7 +57,7 @@ class SessionStore {
38
57
  const objective = `${options.objective || ''}`.trim();
39
58
  const requestedId = safeSessionId(options.sessionId);
40
59
  const sessionId = requestedId || generateSessionId();
41
- const now = new Date().toISOString();
60
+ const now = nowIso();
42
61
 
43
62
  await this._steeringContract.ensureContract();
44
63
  const steeringPayload = await this._steeringContract.buildCompilePayload(tool, agentVersion);
@@ -86,7 +105,7 @@ class SessionStore {
86
105
 
87
106
  async resumeSession(sessionRef = 'latest', options = {}) {
88
107
  const { sessionId, session } = await this._resolveSession(sessionRef);
89
- const now = new Date().toISOString();
108
+ const now = nowIso();
90
109
  const status = `${options.status || 'active'}`.trim() || 'active';
91
110
 
92
111
  session.status = status;
@@ -104,7 +123,7 @@ class SessionStore {
104
123
 
105
124
  async snapshotSession(sessionRef = 'latest', options = {}) {
106
125
  const { sessionId, session } = await this._resolveSession(sessionRef);
107
- const now = new Date().toISOString();
126
+ const now = nowIso();
108
127
  const summary = `${options.summary || ''}`.trim();
109
128
  const status = options.status ? `${options.status}`.trim() : session.status;
110
129
  const payload = options.payload == null ? null : options.payload;
@@ -167,6 +186,414 @@ class SessionStore {
167
186
  return records;
168
187
  }
169
188
 
189
+ async beginSceneSession(options = {}) {
190
+ const sceneId = `${options.sceneId || ''}`.trim();
191
+ if (!sceneId) {
192
+ throw new Error('sceneId is required for beginSceneSession');
193
+ }
194
+
195
+ const forceNew = options.forceNew === true;
196
+ const sceneIndex = await this._readSceneIndex();
197
+ const sceneRecord = sceneIndex.scenes[sceneId] || {
198
+ scene_id: sceneId,
199
+ active_session_id: null,
200
+ active_cycle: null,
201
+ latest_completed_session_id: null,
202
+ last_cycle: 0,
203
+ cycles: []
204
+ };
205
+
206
+ if (!forceNew && sceneRecord.active_session_id) {
207
+ try {
208
+ const activeSession = await this.getSession(sceneRecord.active_session_id);
209
+ if (
210
+ activeSession &&
211
+ activeSession.status === 'active' &&
212
+ activeSession.scene &&
213
+ activeSession.scene.id === sceneId
214
+ ) {
215
+ return {
216
+ created_new: false,
217
+ scene_cycle: Number(sceneRecord.active_cycle || 1),
218
+ session: activeSession,
219
+ scene_record: sceneRecord
220
+ };
221
+ }
222
+ } catch (_error) {
223
+ // stale active pointer; continue and create a new one
224
+ }
225
+ }
226
+
227
+ const cycle = Number(sceneRecord.last_cycle || 0) + 1;
228
+ const requestedSessionId = safeSessionId(options.sessionId);
229
+ const generatedScenePrefix = `scene-${safeSceneId(sceneId)}-c${cycle}`;
230
+ const candidateSessionId = requestedSessionId || `${generatedScenePrefix}-${Math.random().toString(36).slice(2, 8)}`;
231
+
232
+ const session = await this.startSession({
233
+ tool: options.tool || 'generic',
234
+ agentVersion: options.agentVersion || null,
235
+ objective: options.objective || `Scene ${sceneId} cycle ${cycle}`,
236
+ sessionId: candidateSessionId
237
+ });
238
+
239
+ const now = nowIso();
240
+ session.scene = {
241
+ id: sceneId,
242
+ role: 'primary',
243
+ cycle,
244
+ state: 'active'
245
+ };
246
+ session.children = session.children || {};
247
+ session.children.spec_sessions = Array.isArray(session.children.spec_sessions)
248
+ ? session.children.spec_sessions
249
+ : [];
250
+ session.updated_at = now;
251
+ session.timeline = Array.isArray(session.timeline) ? session.timeline : [];
252
+ session.timeline.push({
253
+ at: now,
254
+ event: 'scene_session_started',
255
+ detail: {
256
+ scene_id: sceneId,
257
+ cycle
258
+ }
259
+ });
260
+ await this._writeSession(session.session_id, session);
261
+
262
+ sceneRecord.active_session_id = session.session_id;
263
+ sceneRecord.active_cycle = cycle;
264
+ sceneRecord.last_cycle = cycle;
265
+ sceneRecord.updated_at = now;
266
+ sceneRecord.cycles = Array.isArray(sceneRecord.cycles) ? sceneRecord.cycles : [];
267
+ sceneRecord.cycles.push({
268
+ cycle,
269
+ session_id: session.session_id,
270
+ status: 'active',
271
+ started_at: session.started_at,
272
+ completed_at: null
273
+ });
274
+ sceneIndex.scenes[sceneId] = sceneRecord;
275
+ await this._writeSceneIndex(sceneIndex);
276
+
277
+ return {
278
+ created_new: true,
279
+ scene_cycle: cycle,
280
+ session,
281
+ scene_record: sceneRecord
282
+ };
283
+ }
284
+
285
+ async completeSceneSession(sceneId, sessionRef = null, options = {}) {
286
+ const normalizedSceneId = `${sceneId || ''}`.trim();
287
+ if (!normalizedSceneId) {
288
+ throw new Error('sceneId is required for completeSceneSession');
289
+ }
290
+
291
+ const sceneIndex = await this._readSceneIndex();
292
+ const sceneRecord = sceneIndex.scenes[normalizedSceneId];
293
+ if (!sceneRecord) {
294
+ throw new Error(`Scene is not tracked in session governance: ${normalizedSceneId}`);
295
+ }
296
+
297
+ const resolvedSessionRef = `${sessionRef || sceneRecord.active_session_id || ''}`.trim();
298
+ if (!resolvedSessionRef) {
299
+ throw new Error(`No active scene session to complete: ${normalizedSceneId}`);
300
+ }
301
+
302
+ const { sessionId, session } = await this._resolveSession(resolvedSessionRef);
303
+ if (!session.scene || session.scene.id !== normalizedSceneId) {
304
+ throw new Error(`Session ${sessionId} is not bound to scene ${normalizedSceneId}`);
305
+ }
306
+
307
+ const now = nowIso();
308
+ const completionStatus = `${options.status || 'completed'}`.trim() || 'completed';
309
+ const summary = `${options.summary || ''}`.trim();
310
+ const payload = {
311
+ job_id: options.jobId || null,
312
+ release_ref: options.releaseRef || null,
313
+ channel: options.channel || null
314
+ };
315
+
316
+ session.snapshots = Array.isArray(session.snapshots) ? session.snapshots : [];
317
+ session.snapshots.push({
318
+ snapshot_id: nextSnapshotId(session),
319
+ captured_at: now,
320
+ status: completionStatus,
321
+ summary,
322
+ payload
323
+ });
324
+ session.status = completionStatus;
325
+ session.updated_at = now;
326
+ session.scene.state = 'completed';
327
+ session.scene.completed_at = now;
328
+ session.timeline = Array.isArray(session.timeline) ? session.timeline : [];
329
+ session.timeline.push({
330
+ at: now,
331
+ event: 'scene_session_completed',
332
+ detail: {
333
+ scene_id: normalizedSceneId,
334
+ release_ref: payload.release_ref,
335
+ channel: payload.channel
336
+ }
337
+ });
338
+ await this._writeSession(sessionId, session);
339
+
340
+ sceneRecord.cycles = Array.isArray(sceneRecord.cycles) ? sceneRecord.cycles : [];
341
+ for (const cycle of sceneRecord.cycles) {
342
+ if (cycle && cycle.session_id === sessionId) {
343
+ cycle.status = completionStatus;
344
+ cycle.completed_at = now;
345
+ cycle.release_ref = payload.release_ref;
346
+ }
347
+ }
348
+ sceneRecord.active_session_id = null;
349
+ sceneRecord.active_cycle = null;
350
+ sceneRecord.latest_completed_session_id = sessionId;
351
+ sceneRecord.updated_at = now;
352
+ sceneIndex.scenes[normalizedSceneId] = sceneRecord;
353
+ await this._writeSceneIndex(sceneIndex);
354
+
355
+ if (options.autoStartNext === false) {
356
+ return {
357
+ completed_session: session,
358
+ next_session: null,
359
+ next_scene_cycle: null
360
+ };
361
+ }
362
+
363
+ const next = await this.beginSceneSession({
364
+ sceneId: normalizedSceneId,
365
+ tool: session.tool,
366
+ agentVersion: session.agent_version || null,
367
+ objective: options.nextObjective || `Scene ${normalizedSceneId} follow-up cycle`
368
+ });
369
+ next.session.timeline = Array.isArray(next.session.timeline) ? next.session.timeline : [];
370
+ next.session.timeline.push({
371
+ at: nowIso(),
372
+ event: 'scene_session_rollover',
373
+ detail: {
374
+ from_session_id: sessionId,
375
+ scene_id: normalizedSceneId
376
+ }
377
+ });
378
+ await this._writeSession(next.session.session_id, next.session);
379
+
380
+ return {
381
+ completed_session: session,
382
+ next_session: next.session,
383
+ next_scene_cycle: next.scene_cycle
384
+ };
385
+ }
386
+
387
+ async startSpecSession(options = {}) {
388
+ const sceneId = `${options.sceneId || ''}`.trim();
389
+ const specId = `${options.specId || ''}`.trim();
390
+ if (!sceneId) {
391
+ throw new Error('sceneId is required for startSpecSession');
392
+ }
393
+ if (!specId) {
394
+ throw new Error('specId is required for startSpecSession');
395
+ }
396
+
397
+ const sceneIndex = await this._readSceneIndex();
398
+ const sceneRecord = sceneIndex.scenes[sceneId];
399
+ if (!sceneRecord || !sceneRecord.active_session_id) {
400
+ throw new Error(`No active scene session for scene: ${sceneId}`);
401
+ }
402
+
403
+ const parentSession = await this.getSession(sceneRecord.active_session_id);
404
+ const cycle = parentSession && parentSession.scene && parentSession.scene.cycle
405
+ ? Number(parentSession.scene.cycle)
406
+ : Number(sceneRecord.active_cycle || 1);
407
+ const specSessionId = safeSessionId(options.sessionId)
408
+ || `spec-${safeSceneId(specId)}-${Math.random().toString(36).slice(2, 8)}`;
409
+
410
+ const specSession = await this.startSession({
411
+ tool: parentSession.tool || options.tool || 'generic',
412
+ agentVersion: parentSession.agent_version || options.agentVersion || null,
413
+ objective: options.objective || `Spec ${specId} for scene ${sceneId}`,
414
+ sessionId: specSessionId
415
+ });
416
+
417
+ const now = nowIso();
418
+ specSession.scene = {
419
+ id: sceneId,
420
+ role: 'spec',
421
+ cycle,
422
+ parent_session_id: parentSession.session_id,
423
+ spec_id: specId,
424
+ state: 'active'
425
+ };
426
+ specSession.updated_at = now;
427
+ specSession.timeline = Array.isArray(specSession.timeline) ? specSession.timeline : [];
428
+ specSession.timeline.push({
429
+ at: now,
430
+ event: 'spec_session_started',
431
+ detail: {
432
+ scene_id: sceneId,
433
+ spec_id: specId,
434
+ parent_session_id: parentSession.session_id
435
+ }
436
+ });
437
+ await this._writeSession(specSession.session_id, specSession);
438
+
439
+ parentSession.children = parentSession.children || {};
440
+ parentSession.children.spec_sessions = Array.isArray(parentSession.children.spec_sessions)
441
+ ? parentSession.children.spec_sessions
442
+ : [];
443
+ parentSession.children.spec_sessions.push({
444
+ spec_id: specId,
445
+ spec_session_id: specSession.session_id,
446
+ status: 'active',
447
+ started_at: now
448
+ });
449
+ parentSession.updated_at = now;
450
+ parentSession.timeline = Array.isArray(parentSession.timeline) ? parentSession.timeline : [];
451
+ parentSession.timeline.push({
452
+ at: now,
453
+ event: 'spec_session_attached',
454
+ detail: {
455
+ scene_id: sceneId,
456
+ spec_id: specId,
457
+ spec_session_id: specSession.session_id
458
+ }
459
+ });
460
+ await this._writeSession(parentSession.session_id, parentSession);
461
+
462
+ return {
463
+ scene_session: parentSession,
464
+ spec_session: specSession
465
+ };
466
+ }
467
+
468
+ async completeSpecSession(options = {}) {
469
+ const specSessionRef = `${options.specSessionRef || ''}`.trim();
470
+ if (!specSessionRef) {
471
+ throw new Error('specSessionRef is required for completeSpecSession');
472
+ }
473
+
474
+ const now = nowIso();
475
+ const summary = `${options.summary || ''}`.trim();
476
+ const status = `${options.status || 'completed'}`.trim() || 'completed';
477
+ const payload = options.payload == null ? null : options.payload;
478
+
479
+ const { sessionId, session } = await this._resolveSession(specSessionRef);
480
+ if (!session.scene || session.scene.role !== 'spec') {
481
+ throw new Error(`Session ${sessionId} is not a spec child session`);
482
+ }
483
+
484
+ session.snapshots = Array.isArray(session.snapshots) ? session.snapshots : [];
485
+ session.snapshots.push({
486
+ snapshot_id: nextSnapshotId(session),
487
+ captured_at: now,
488
+ status,
489
+ summary,
490
+ payload
491
+ });
492
+ session.status = status;
493
+ session.scene.state = 'completed';
494
+ session.scene.completed_at = now;
495
+ session.updated_at = now;
496
+ session.timeline = Array.isArray(session.timeline) ? session.timeline : [];
497
+ session.timeline.push({
498
+ at: now,
499
+ event: 'spec_session_completed',
500
+ detail: {
501
+ scene_id: session.scene.id,
502
+ spec_id: session.scene.spec_id
503
+ }
504
+ });
505
+ await this._writeSession(sessionId, session);
506
+
507
+ if (session.scene.parent_session_id) {
508
+ try {
509
+ const parent = await this.getSession(session.scene.parent_session_id);
510
+ parent.children = parent.children || {};
511
+ parent.children.spec_sessions = Array.isArray(parent.children.spec_sessions)
512
+ ? parent.children.spec_sessions
513
+ : [];
514
+ parent.children.spec_sessions = parent.children.spec_sessions.map((entry) => {
515
+ if (entry && entry.spec_session_id === sessionId) {
516
+ return {
517
+ ...entry,
518
+ status,
519
+ completed_at: now
520
+ };
521
+ }
522
+ return entry;
523
+ });
524
+ parent.updated_at = now;
525
+ parent.timeline = Array.isArray(parent.timeline) ? parent.timeline : [];
526
+ parent.timeline.push({
527
+ at: now,
528
+ event: 'spec_session_archived',
529
+ detail: {
530
+ scene_id: session.scene.id,
531
+ spec_id: session.scene.spec_id,
532
+ spec_session_id: sessionId
533
+ }
534
+ });
535
+ await this._writeSession(parent.session_id, parent);
536
+ } catch (_error) {
537
+ // parent may have been removed manually; spec session has already been archived
538
+ }
539
+ }
540
+
541
+ return session;
542
+ }
543
+
544
+ async listSceneRecords() {
545
+ const sceneIndex = await this._readSceneIndex();
546
+ return Object.values(sceneIndex.scenes || {});
547
+ }
548
+
549
+ async listActiveSceneSessions() {
550
+ const records = await this.listSceneRecords();
551
+ const active = [];
552
+ for (const record of records) {
553
+ if (!record || !record.active_session_id) {
554
+ continue;
555
+ }
556
+ try {
557
+ const session = await this.getSession(record.active_session_id);
558
+ if (session && session.status === 'active') {
559
+ active.push({
560
+ scene_id: record.scene_id,
561
+ scene_cycle: record.active_cycle,
562
+ session
563
+ });
564
+ }
565
+ } catch (_error) {
566
+ // skip stale references
567
+ }
568
+ }
569
+ return active;
570
+ }
571
+
572
+ async getActiveSceneSession(sceneId) {
573
+ const normalizedSceneId = `${sceneId || ''}`.trim();
574
+ if (!normalizedSceneId) {
575
+ throw new Error('sceneId is required for getActiveSceneSession');
576
+ }
577
+ const sceneIndex = await this._readSceneIndex();
578
+ const record = sceneIndex.scenes[normalizedSceneId];
579
+ if (!record || !record.active_session_id) {
580
+ return null;
581
+ }
582
+ try {
583
+ const session = await this.getSession(record.active_session_id);
584
+ if (!session || session.status !== 'active') {
585
+ return null;
586
+ }
587
+ return {
588
+ scene_id: normalizedSceneId,
589
+ scene_cycle: record.active_cycle,
590
+ session
591
+ };
592
+ } catch (_error) {
593
+ return null;
594
+ }
595
+ }
596
+
170
597
  async _resolveSession(sessionRef) {
171
598
  const ref = `${sessionRef || 'latest'}`.trim();
172
599
  if (ref === 'latest') {
@@ -195,6 +622,48 @@ class SessionStore {
195
622
  await fs.writeJson(this._sessionPath(sessionId), session, { spaces: 2 });
196
623
  }
197
624
 
625
+ async _readSceneIndex() {
626
+ if (!await fs.pathExists(this._sceneIndexPath)) {
627
+ return {
628
+ schema_version: SESSION_GOVERNANCE_SCHEMA_VERSION,
629
+ updated_at: nowIso(),
630
+ scenes: {}
631
+ };
632
+ }
633
+
634
+ try {
635
+ const payload = await fs.readJson(this._sceneIndexPath);
636
+ return {
637
+ schema_version: payload && payload.schema_version
638
+ ? payload.schema_version
639
+ : SESSION_GOVERNANCE_SCHEMA_VERSION,
640
+ updated_at: payload && payload.updated_at ? payload.updated_at : nowIso(),
641
+ scenes: payload && typeof payload.scenes === 'object' && payload.scenes
642
+ ? payload.scenes
643
+ : {}
644
+ };
645
+ } catch (_error) {
646
+ return {
647
+ schema_version: SESSION_GOVERNANCE_SCHEMA_VERSION,
648
+ updated_at: nowIso(),
649
+ scenes: {}
650
+ };
651
+ }
652
+ }
653
+
654
+ async _writeSceneIndex(indexPayload) {
655
+ const payload = {
656
+ schema_version: SESSION_GOVERNANCE_SCHEMA_VERSION,
657
+ updated_at: nowIso(),
658
+ scenes: indexPayload && typeof indexPayload.scenes === 'object' && indexPayload.scenes
659
+ ? indexPayload.scenes
660
+ : {}
661
+ };
662
+ await fs.ensureDir(this._sessionGovernanceDir);
663
+ await fs.writeJson(this._sceneIndexPath, payload, { spaces: 2 });
664
+ return payload;
665
+ }
666
+
198
667
  _sessionPath(sessionId) {
199
668
  return path.join(this._sessionsDir, `${sessionId}.json`);
200
669
  }
@@ -204,4 +673,7 @@ module.exports = {
204
673
  SessionStore,
205
674
  SESSION_SCHEMA_VERSION,
206
675
  SESSION_DIR,
676
+ SESSION_GOVERNANCE_SCHEMA_VERSION,
677
+ SESSION_GOVERNANCE_DIR,
678
+ SESSION_SCENE_INDEX_FILE,
207
679
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scene-capability-engine",
3
- "version": "3.3.18",
3
+ "version": "3.3.21",
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": {
@@ -68,10 +68,12 @@
68
68
  "report:interactive-governance": "node scripts/interactive-governance-report.js --period weekly --json",
69
69
  "report:release-ops-weekly": "node scripts/release-ops-weekly-summary.js --json",
70
70
  "gate:release-ops-weekly": "node scripts/release-weekly-ops-gate.js",
71
+ "gate:errorbook-release": "node scripts/errorbook-release-gate.js --fail-on-block",
72
+ "gate:git-managed": "node scripts/git-managed-gate.js --fail-on-violation",
71
73
  "gate:release-asset-integrity": "node scripts/release-asset-integrity-check.js",
72
74
  "report:release-risk-remediation": "node scripts/release-risk-remediation-bundle.js --json",
73
75
  "report:moqui-core-regression": "node scripts/moqui-core-regression-suite.js --json",
74
- "prepublishOnly": "npm run test:full && npm run test:skip-audit && npm run test:sce-tracking && npm run test:brand-consistency && npm run report:interactive-governance -- --fail-on-alert",
76
+ "prepublishOnly": "npm run test:full && npm run test:skip-audit && npm run test:sce-tracking && npm run test:brand-consistency && npm run gate:git-managed && npm run gate:errorbook-release && npm run report:interactive-governance -- --fail-on-alert",
75
77
  "publish:manual": "npm publish --access public",
76
78
  "install-global": "npm install -g .",
77
79
  "uninstall-global": "npm uninstall -g scene-capability-engine"
@@ -102,6 +102,19 @@
102
102
 
103
103
  **后果**: 违反导致用户困惑、功能难发现、门槛高、维护成本增
104
104
 
105
+ ### 8.1 Git 托管强制门禁原则 🔐
106
+
107
+ **核心**: 新增或修改代码必须进入 Git 托管管理链路,禁止“本地改完未推送”进入发布流程
108
+
109
+ **默认强制**: 若仓库配置了 GitHub/GitLab 远端,发布前必须满足:
110
+ 1) 工作区干净(无未提交变更)
111
+ 2) 当前分支已配置 upstream
112
+ 3) 与 upstream 完全同步(ahead=0, behind=0)
113
+
114
+ **豁免条件**: 仅当客户环境确实没有 GitHub/GitLab 托管时允许放行(策略控制),并需在交付记录中声明原因
115
+
116
+ **落地门禁**: `scripts/git-managed-gate.js`(`prepublishOnly` 与 release preflight 默认执行)
117
+
105
118
  ### 9. 版本同步和 Steering 刷新原则
106
119
 
107
120
  **核心**: 版本更新或首次安装后,必须阅读 `.sce/README.md` 并刷新 Steering
@@ -155,6 +168,18 @@
155
168
 
156
169
  **要求**: 需求分析、问题定位、模板沉淀、发布门禁均按该口径检查完整性与一致性
157
170
 
171
+ ### 13. Scene 主会话强制治理原则 🎛️
172
+
173
+ **核心**: SCE 默认并强制采用 `1 Scene = 1 主会话`,会话生命周期由 Scene 驱动而不是由单个 Agent 的原生 session 决定
174
+
175
+ **硬规则**:
176
+ 1) `studio plan` 必须提供 `--scene`,并绑定 Scene 主会话
177
+ 2) 同一 Scene 同时只允许一个活动主会话
178
+ 3) `Spec` 必须作为该 Scene 主会话下的子会话管理(创建、归档、摘要回写)
179
+ 4) Scene 完成(如 release 成功)后,当前主会话自动归档,并自动开启下一周期主会话
180
+
181
+ **目标**: 保证跨 Agent 连续性、可追溯历史、上下文可控,避免因 Agent session 长短差异导致上下文漂移
182
+
158
183
  ---
159
184
 
160
- v16.0 | 2026-02-25 | 新增调试日志清理硬规则
185
+ v17.0 | 2026-02-26 | 新增 Scene 主会话强制治理原则