scene-capability-engine 3.6.57 → 3.6.59

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +5 -3
  3. package/README.zh.md +5 -3
  4. package/bin/scene-capability-engine.js +2 -0
  5. package/docs/command-reference.md +72 -0
  6. package/docs/magicball-adaptation-task-checklist-v1.md +65 -10
  7. package/docs/magicball-cli-invocation-examples.md +53 -8
  8. package/docs/magicball-engineering-projection-contract.md +175 -0
  9. package/docs/magicball-frontend-state-and-command-mapping.md +42 -5
  10. package/docs/magicball-integration-doc-index.md +19 -5
  11. package/docs/magicball-integration-issue-tracker.md +15 -5
  12. package/docs/magicball-mode-home-and-ontology-empty-state-playbook.md +13 -5
  13. package/docs/magicball-project-portfolio-contract.md +216 -0
  14. package/docs/magicball-sce-adaptation-guide.md +18 -4
  15. package/docs/magicball-ui-surface-checklist.md +25 -0
  16. package/docs/magicball-write-auth-adaptation-guide.md +3 -1
  17. package/docs/release-checklist.md +8 -0
  18. package/docs/releases/README.md +2 -0
  19. package/docs/releases/v3.6.58.md +27 -0
  20. package/docs/releases/v3.6.59.md +18 -0
  21. package/docs/zh/release-checklist.md +8 -0
  22. package/docs/zh/releases/README.md +2 -0
  23. package/docs/zh/releases/v3.6.58.md +27 -0
  24. package/docs/zh/releases/v3.6.59.md +18 -0
  25. package/lib/app/engineering-scaffold-service.js +154 -0
  26. package/lib/commands/app.js +442 -13
  27. package/lib/commands/project.js +105 -0
  28. package/lib/commands/scene.js +16 -0
  29. package/lib/project/portfolio-projection-service.js +389 -0
  30. package/lib/project/supervision-projection-service.js +329 -0
  31. package/lib/project/target-resolution-service.js +180 -0
  32. package/lib/scene/delivery-projection-service.js +650 -0
  33. package/package.json +6 -2
  34. package/scripts/magicball-engineering-contract-audit.js +347 -0
  35. package/scripts/magicball-project-contract-audit.js +254 -0
  36. package/template/.sce/README.md +2 -2
@@ -0,0 +1,389 @@
1
+ const crypto = require('crypto');
2
+ const path = require('path');
3
+ const fs = require('fs-extra');
4
+ const WorkspaceStateManager = require('../workspace/multi/workspace-state-manager');
5
+ const { SessionStore } = require('../runtime/session-store');
6
+ const { getCurrentDeviceProfile } = require('../device/current-device');
7
+
8
+ function normalizeString(value) {
9
+ if (typeof value !== 'string') {
10
+ return '';
11
+ }
12
+ return value.trim();
13
+ }
14
+
15
+ function normalizePath(value) {
16
+ return normalizeString(value).replace(/\\/g, '/');
17
+ }
18
+
19
+ function buildWorkspaceProjectId(workspaceId) {
20
+ return `workspace:${workspaceId}`;
21
+ }
22
+
23
+ function buildLocalProjectId(projectRoot) {
24
+ const hash = crypto
25
+ .createHash('sha1')
26
+ .update(normalizePath(projectRoot))
27
+ .digest('hex')
28
+ .slice(0, 12);
29
+ return `local:${hash}`;
30
+ }
31
+
32
+ function safeIsoAt(value) {
33
+ const normalized = normalizeString(value);
34
+ if (!normalized) {
35
+ return null;
36
+ }
37
+ const time = Date.parse(normalized);
38
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
39
+ }
40
+
41
+ function collectRecordActivityTimestamps(sceneRecords = []) {
42
+ const timestamps = [];
43
+ for (const record of sceneRecords) {
44
+ const updatedAt = safeIsoAt(record && record.updated_at);
45
+ if (updatedAt) {
46
+ timestamps.push(updatedAt);
47
+ }
48
+ const cycles = Array.isArray(record && record.cycles) ? record.cycles : [];
49
+ for (const cycle of cycles) {
50
+ const startedAt = safeIsoAt(cycle && cycle.started_at);
51
+ const completedAt = safeIsoAt(cycle && cycle.completed_at);
52
+ if (startedAt) {
53
+ timestamps.push(startedAt);
54
+ }
55
+ if (completedAt) {
56
+ timestamps.push(completedAt);
57
+ }
58
+ }
59
+ }
60
+ timestamps.sort((left, right) => right.localeCompare(left));
61
+ return timestamps[0] || null;
62
+ }
63
+
64
+ async function isValidSceProjectRoot(projectRoot, fileSystem = fs) {
65
+ const sceRoot = path.join(projectRoot, '.sce');
66
+ if (!await fileSystem.pathExists(sceRoot)) {
67
+ return false;
68
+ }
69
+ try {
70
+ const stats = await fileSystem.stat(sceRoot);
71
+ return stats.isDirectory();
72
+ } catch (_error) {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ async function countSpecDirectories(projectRoot, fileSystem = fs) {
78
+ const specsRoot = path.join(projectRoot, '.sce', 'specs');
79
+ if (!await fileSystem.pathExists(specsRoot)) {
80
+ return 0;
81
+ }
82
+ const entries = await fileSystem.readdir(specsRoot);
83
+ let count = 0;
84
+ for (const entry of entries) {
85
+ try {
86
+ const stats = await fileSystem.stat(path.join(specsRoot, entry));
87
+ if (stats.isDirectory()) {
88
+ count += 1;
89
+ }
90
+ } catch (_error) {
91
+ // Ignore unreadable spec directories and keep reporting partial state elsewhere.
92
+ }
93
+ }
94
+ return count;
95
+ }
96
+
97
+ function deriveAvailability({ accessible, partial }) {
98
+ if (!accessible) {
99
+ return 'inaccessible';
100
+ }
101
+ if (partial) {
102
+ return 'degraded';
103
+ }
104
+ return 'accessible';
105
+ }
106
+
107
+ function deriveReadiness({ accessible, partial, sceneCount, specCount }) {
108
+ if (!accessible) {
109
+ return 'unknown';
110
+ }
111
+ if (partial) {
112
+ return 'partial';
113
+ }
114
+ if (sceneCount === 0 && specCount === 0) {
115
+ return 'pending';
116
+ }
117
+ return 'ready';
118
+ }
119
+
120
+ function deriveStatus({ accessible, isCurrentProject, activeSessionCount }) {
121
+ if (!accessible) {
122
+ return 'inaccessible';
123
+ }
124
+ if (isCurrentProject) {
125
+ return 'active';
126
+ }
127
+ if (activeSessionCount > 0) {
128
+ return 'background';
129
+ }
130
+ return 'idle';
131
+ }
132
+
133
+ async function readSceneRecords(projectRoot, dependencies = {}) {
134
+ const fileSystem = dependencies.fileSystem || fs;
135
+ const env = dependencies.env || process.env;
136
+ const sqliteModule = dependencies.sqliteModule;
137
+ const sessionStore = new SessionStore(projectRoot, null, {
138
+ fileSystem,
139
+ env,
140
+ sqliteModule
141
+ });
142
+ return sessionStore.listSceneRecords();
143
+ }
144
+
145
+ async function buildRegisteredWorkspaceRecord(workspace, context = {}, dependencies = {}) {
146
+ const fileSystem = dependencies.fileSystem || fs;
147
+ const workspaceId = normalizeString(workspace && workspace.name);
148
+ const projectRoot = normalizePath(workspace && workspace.path);
149
+ const partialReasons = [];
150
+ const projectId = buildWorkspaceProjectId(workspaceId);
151
+ let accessible = false;
152
+ let partial = false;
153
+ let sceneRecords = [];
154
+ let specCount = 0;
155
+
156
+ const sceProject = await isValidSceProjectRoot(projectRoot, fileSystem);
157
+ if (!sceProject) {
158
+ partial = true;
159
+ partialReasons.push('workspace_root_unavailable');
160
+ } else {
161
+ accessible = true;
162
+ try {
163
+ sceneRecords = await readSceneRecords(projectRoot, dependencies);
164
+ } catch (_error) {
165
+ partial = true;
166
+ partialReasons.push('scene_records_unavailable');
167
+ }
168
+ try {
169
+ specCount = await countSpecDirectories(projectRoot, fileSystem);
170
+ } catch (_error) {
171
+ partial = true;
172
+ partialReasons.push('spec_inventory_unavailable');
173
+ }
174
+ }
175
+
176
+ const sceneCount = Array.isArray(sceneRecords) ? sceneRecords.length : 0;
177
+ const activeSessionCount = Array.isArray(sceneRecords)
178
+ ? sceneRecords.filter((record) => normalizeString(record && record.active_session_id)).length
179
+ : 0;
180
+ const lastActivityAt = collectRecordActivityTimestamps(sceneRecords);
181
+ const uniquePartialReasons = Array.from(new Set(partialReasons));
182
+ const isCurrentProject = context.projectId === projectId;
183
+
184
+ return {
185
+ projectId,
186
+ workspaceId,
187
+ projectRoot,
188
+ projectName: path.basename(projectRoot) || workspaceId,
189
+ provenance: 'registered',
190
+ readiness: deriveReadiness({
191
+ accessible,
192
+ partial,
193
+ sceneCount,
194
+ specCount
195
+ }),
196
+ status: deriveStatus({
197
+ accessible,
198
+ isCurrentProject,
199
+ activeSessionCount
200
+ }),
201
+ availability: deriveAvailability({
202
+ accessible,
203
+ partial
204
+ }),
205
+ activeSessionCount,
206
+ ...(lastActivityAt ? { lastActivityAt } : {}),
207
+ summary: {
208
+ sceneCount,
209
+ specCount
210
+ },
211
+ partial,
212
+ partialReasons: uniquePartialReasons
213
+ };
214
+ }
215
+
216
+ async function buildLocalProjectRecord(projectRoot, context = {}, dependencies = {}) {
217
+ const fileSystem = dependencies.fileSystem || fs;
218
+ const normalizedRoot = normalizePath(projectRoot);
219
+ const projectId = buildLocalProjectId(normalizedRoot);
220
+ let sceneRecords = [];
221
+ let specCount = 0;
222
+ const partialReasons = ['unregistered_project'];
223
+ let partial = true;
224
+
225
+ try {
226
+ sceneRecords = await readSceneRecords(normalizedRoot, dependencies);
227
+ } catch (_error) {
228
+ partialReasons.push('scene_records_unavailable');
229
+ }
230
+ try {
231
+ specCount = await countSpecDirectories(normalizedRoot, fileSystem);
232
+ } catch (_error) {
233
+ partialReasons.push('spec_inventory_unavailable');
234
+ }
235
+
236
+ const sceneCount = Array.isArray(sceneRecords) ? sceneRecords.length : 0;
237
+ const activeSessionCount = Array.isArray(sceneRecords)
238
+ ? sceneRecords.filter((record) => normalizeString(record && record.active_session_id)).length
239
+ : 0;
240
+ const lastActivityAt = collectRecordActivityTimestamps(sceneRecords);
241
+ const uniquePartialReasons = Array.from(new Set(partialReasons));
242
+ const isCurrentProject = context.projectId === projectId;
243
+
244
+ return {
245
+ projectId,
246
+ projectRoot: normalizedRoot,
247
+ projectName: path.basename(normalizedRoot) || 'local-project',
248
+ provenance: 'discovered',
249
+ readiness: deriveReadiness({
250
+ accessible: true,
251
+ partial,
252
+ sceneCount,
253
+ specCount
254
+ }),
255
+ status: deriveStatus({
256
+ accessible: true,
257
+ isCurrentProject,
258
+ activeSessionCount
259
+ }),
260
+ availability: deriveAvailability({
261
+ accessible: true,
262
+ partial
263
+ }),
264
+ activeSessionCount,
265
+ ...(lastActivityAt ? { lastActivityAt } : {}),
266
+ summary: {
267
+ sceneCount,
268
+ specCount
269
+ },
270
+ partial,
271
+ partialReasons: uniquePartialReasons
272
+ };
273
+ }
274
+
275
+ async function resolveCurrentProjectContext(options = {}, dependencies = {}) {
276
+ const stateManager = dependencies.stateManager || new WorkspaceStateManager(dependencies.workspaceStatePath);
277
+ const currentDir = normalizePath(dependencies.projectPath || process.cwd());
278
+ const explicitWorkspace = normalizeString(options.workspace);
279
+
280
+ if (explicitWorkspace) {
281
+ const workspace = await stateManager.getWorkspace(explicitWorkspace);
282
+ if (!workspace) {
283
+ throw new Error(`workspace not found: ${explicitWorkspace}`);
284
+ }
285
+ return {
286
+ workspaceId: workspace.name,
287
+ projectId: buildWorkspaceProjectId(workspace.name),
288
+ projectRoot: normalizePath(workspace.path),
289
+ source: 'explicit-workspace'
290
+ };
291
+ }
292
+
293
+ const matchedWorkspace = await stateManager.findWorkspaceByPath(currentDir);
294
+ if (matchedWorkspace) {
295
+ return {
296
+ workspaceId: matchedWorkspace.name,
297
+ projectId: buildWorkspaceProjectId(matchedWorkspace.name),
298
+ projectRoot: normalizePath(matchedWorkspace.path),
299
+ source: 'current-directory'
300
+ };
301
+ }
302
+
303
+ const activeWorkspace = await stateManager.getActiveWorkspace();
304
+ if (activeWorkspace) {
305
+ return {
306
+ workspaceId: activeWorkspace.name,
307
+ projectId: buildWorkspaceProjectId(activeWorkspace.name),
308
+ projectRoot: normalizePath(activeWorkspace.path),
309
+ source: 'active-workspace'
310
+ };
311
+ }
312
+
313
+ if (await isValidSceProjectRoot(currentDir, dependencies.fileSystem || fs)) {
314
+ return {
315
+ workspaceId: null,
316
+ projectId: buildLocalProjectId(currentDir),
317
+ projectRoot: currentDir,
318
+ source: 'local-project'
319
+ };
320
+ }
321
+
322
+ return {
323
+ workspaceId: null,
324
+ projectId: null,
325
+ projectRoot: null,
326
+ source: 'none'
327
+ };
328
+ }
329
+
330
+ async function buildProjectPortfolioProjection(options = {}, dependencies = {}) {
331
+ const stateManager = dependencies.stateManager || new WorkspaceStateManager(dependencies.workspaceStatePath);
332
+ const fileSystem = dependencies.fileSystem || fs;
333
+ const currentContext = await resolveCurrentProjectContext(options, {
334
+ ...dependencies,
335
+ stateManager
336
+ });
337
+ let currentDevice = null;
338
+
339
+ try {
340
+ currentDevice = await getCurrentDeviceProfile(dependencies.projectPath || process.cwd(), {
341
+ fileSystem,
342
+ persistIfMissing: false
343
+ });
344
+ } catch (_error) {
345
+ currentDevice = null;
346
+ }
347
+
348
+ const workspaces = await stateManager.listWorkspaces();
349
+ const records = [];
350
+ for (const workspace of workspaces) {
351
+ records.push(await buildRegisteredWorkspaceRecord(workspace, currentContext, dependencies));
352
+ }
353
+
354
+ const localRoot = normalizeString(currentContext.projectRoot);
355
+ const currentDirWorkspace = localRoot ? await stateManager.findWorkspaceByPath(localRoot) : null;
356
+ const shouldAddLocalProject = currentContext.source === 'local-project'
357
+ && localRoot
358
+ && !currentDirWorkspace;
359
+ if (shouldAddLocalProject) {
360
+ records.push(await buildLocalProjectRecord(localRoot, currentContext, dependencies));
361
+ }
362
+
363
+ records.sort((left, right) => {
364
+ const leftCurrent = left.projectId === currentContext.projectId ? 0 : 1;
365
+ const rightCurrent = right.projectId === currentContext.projectId ? 0 : 1;
366
+ if (leftCurrent !== rightCurrent) {
367
+ return leftCurrent - rightCurrent;
368
+ }
369
+ return `${left.projectName || left.projectId}`.localeCompare(`${right.projectName || right.projectId}`);
370
+ });
371
+
372
+ return {
373
+ generatedAt: new Date().toISOString(),
374
+ callerContext: {
375
+ ...(currentContext.workspaceId ? { workspaceId: currentContext.workspaceId } : {}),
376
+ ...(currentContext.projectId ? { projectId: currentContext.projectId } : {}),
377
+ ...(currentDevice && currentDevice.device_id ? { deviceId: currentDevice.device_id } : {})
378
+ },
379
+ ...(currentContext.projectId ? { activeProjectId: currentContext.projectId } : {}),
380
+ projects: records
381
+ };
382
+ }
383
+
384
+ module.exports = {
385
+ buildProjectPortfolioProjection,
386
+ buildWorkspaceProjectId,
387
+ buildLocalProjectId,
388
+ resolveCurrentProjectContext
389
+ };
@@ -0,0 +1,329 @@
1
+ const crypto = require('crypto');
2
+ const path = require('path');
3
+ const fs = require('fs-extra');
4
+ const TaskClaimer = require('../task/task-claimer');
5
+ const { SessionStore } = require('../runtime/session-store');
6
+ const { buildProjectPortfolioProjection } = require('./portfolio-projection-service');
7
+
8
+ const SPEC_GOVERNANCE_SCENE_INDEX = path.join('.sce', 'spec-governance', 'scene-index.json');
9
+ const STUDIO_REPORT_DIR = path.join('.sce', 'reports', 'studio');
10
+ const HANDOFF_REPORT_DIR = path.join('.sce', 'reports', 'handoff-runs');
11
+
12
+ function normalizeString(value) {
13
+ if (typeof value !== 'string') {
14
+ return '';
15
+ }
16
+ return value.trim();
17
+ }
18
+
19
+ function safeIsoAt(value) {
20
+ const normalized = normalizeString(value);
21
+ if (!normalized) {
22
+ return null;
23
+ }
24
+ const time = Date.parse(normalized);
25
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
26
+ }
27
+
28
+ function listObjectValues(value) {
29
+ if (!value || typeof value !== 'object') {
30
+ return [];
31
+ }
32
+ if (Array.isArray(value)) {
33
+ return value;
34
+ }
35
+ return Object.values(value);
36
+ }
37
+
38
+ async function readJsonIfExists(filePath, fileSystem = fs) {
39
+ if (!await fileSystem.pathExists(filePath)) {
40
+ return null;
41
+ }
42
+ try {
43
+ return await fileSystem.readJson(filePath);
44
+ } catch (_error) {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ async function listJsonFiles(dirPath, fileSystem = fs) {
50
+ if (!await fileSystem.pathExists(dirPath)) {
51
+ return [];
52
+ }
53
+ const entries = await fileSystem.readdir(dirPath);
54
+ return entries
55
+ .filter((entry) => entry.toLowerCase().endsWith('.json'))
56
+ .map((entry) => path.join(dirPath, entry))
57
+ .sort((left, right) => left.localeCompare(right));
58
+ }
59
+
60
+ function collectLatestTimestamp(items = []) {
61
+ const timestamps = items
62
+ .map((item) => safeIsoAt(item && item.updatedAt))
63
+ .filter(Boolean)
64
+ .sort((left, right) => right.localeCompare(left));
65
+ return timestamps[0] || null;
66
+ }
67
+
68
+ function createCursor(projectId, latestEventAt, itemCount) {
69
+ const hash = crypto
70
+ .createHash('sha1')
71
+ .update(`${projectId}::${latestEventAt || 'none'}::${itemCount}`)
72
+ .digest('hex')
73
+ .slice(0, 12);
74
+ return `${latestEventAt || 'none'}::${itemCount}::${hash}`;
75
+ }
76
+
77
+ function resolveReportSceneId(report = {}) {
78
+ return normalizeString(report.scene_id)
79
+ || normalizeString(report?.domain_chain?.context?.scene_id)
80
+ || normalizeString(report?.domain_chain?.problem_contract?.scene_id);
81
+ }
82
+
83
+ function resolveReportSpecId(report = {}) {
84
+ return normalizeString(report.spec_id)
85
+ || normalizeString(report?.domain_chain?.spec_id);
86
+ }
87
+
88
+ function summarizeTaskCounts(tasks = []) {
89
+ const total = tasks.length;
90
+ const completed = tasks.filter((item) => item.status === 'completed').length;
91
+ const inProgress = tasks.filter((item) => item.status === 'in-progress').length;
92
+ const queued = tasks.filter((item) => item.status === 'queued').length;
93
+ const notStarted = tasks.filter((item) => item.status === 'not-started').length;
94
+ return {
95
+ total,
96
+ completed,
97
+ inProgress,
98
+ queued,
99
+ notStarted,
100
+ active: Math.max(total - completed, 0)
101
+ };
102
+ }
103
+
104
+ async function collectSpecTaskSummary(projectRoot, fileSystem = fs, taskClaimer = new TaskClaimer()) {
105
+ const specsRoot = path.join(projectRoot, '.sce', 'specs');
106
+ if (!await fileSystem.pathExists(specsRoot)) {
107
+ return {
108
+ activeSpecCount: 0,
109
+ activeTaskCount: 0
110
+ };
111
+ }
112
+
113
+ const entries = await fileSystem.readdir(specsRoot);
114
+ let activeSpecCount = 0;
115
+ let activeTaskCount = 0;
116
+
117
+ for (const entry of entries) {
118
+ const specRoot = path.join(specsRoot, entry);
119
+ try {
120
+ const stats = await fileSystem.stat(specRoot);
121
+ if (!stats.isDirectory()) {
122
+ continue;
123
+ }
124
+ const tasksPath = path.join(specRoot, 'tasks.md');
125
+ if (!await fileSystem.pathExists(tasksPath)) {
126
+ continue;
127
+ }
128
+ const tasks = await taskClaimer.parseTasks(tasksPath, { preferStatusMarkers: true });
129
+ const counts = summarizeTaskCounts(tasks);
130
+ if (counts.active > 0) {
131
+ activeSpecCount += 1;
132
+ activeTaskCount += counts.active;
133
+ }
134
+ } catch (_error) {
135
+ // Keep the projection resilient and skip unreadable spec entries.
136
+ }
137
+ }
138
+
139
+ return {
140
+ activeSpecCount,
141
+ activeTaskCount
142
+ };
143
+ }
144
+
145
+ async function buildActiveItems(projectRoot, sceneRecords = []) {
146
+ const items = [];
147
+ for (const record of sceneRecords) {
148
+ const sceneId = normalizeString(record && record.scene_id);
149
+ const sessionId = normalizeString(record && record.active_session_id);
150
+ if (!sceneId || !sessionId) {
151
+ continue;
152
+ }
153
+ const cycles = Array.isArray(record && record.cycles) ? record.cycles : [];
154
+ const activeCycle = cycles.find((item) => normalizeString(item && item.session_id) === sessionId) || {};
155
+ const updatedAt = safeIsoAt(activeCycle.started_at) || safeIsoAt(record.updated_at) || new Date().toISOString();
156
+ items.push({
157
+ id: `active:${sceneId}:${sessionId}`,
158
+ kind: 'active',
159
+ state: 'active',
160
+ sceneId,
161
+ eventId: sessionId,
162
+ updatedAt,
163
+ summary: `Scene ${sceneId} has active session ${sessionId}.`
164
+ });
165
+ }
166
+ return items;
167
+ }
168
+
169
+ async function buildRiskItems(projectRoot, fileSystem = fs) {
170
+ const payload = await readJsonIfExists(path.join(projectRoot, SPEC_GOVERNANCE_SCENE_INDEX), fileSystem);
171
+ const items = [];
172
+ for (const record of listObjectValues(payload && payload.scenes)) {
173
+ const sceneId = normalizeString(record && record.scene_id);
174
+ const staleSpecs = Number(record && record.stale_specs || 0);
175
+ if (!sceneId || staleSpecs <= 0) {
176
+ continue;
177
+ }
178
+ items.push({
179
+ id: `risk:${sceneId}:stale-specs`,
180
+ kind: 'risk',
181
+ state: 'at-risk',
182
+ reasonCode: 'project.stale_specs_present',
183
+ sceneId,
184
+ updatedAt: safeIsoAt(record && record.updated_at) || safeIsoAt(payload && payload.updated_at),
185
+ summary: `Scene ${sceneId} has ${staleSpecs} stale spec(s).`
186
+ });
187
+ }
188
+ return items;
189
+ }
190
+
191
+ async function buildHandoffItems(projectRoot, fileSystem = fs) {
192
+ const files = await listJsonFiles(path.join(projectRoot, HANDOFF_REPORT_DIR), fileSystem);
193
+ const items = [];
194
+ for (const filePath of files) {
195
+ const report = await readJsonIfExists(filePath, fileSystem);
196
+ if (!report || typeof report !== 'object') {
197
+ continue;
198
+ }
199
+ const sceneId = resolveReportSceneId(report) || null;
200
+ const specId = resolveReportSpecId(report) || null;
201
+ const sessionId = normalizeString(report.session_id) || path.basename(filePath, '.json');
202
+ const updatedAt = safeIsoAt(report.generated_at || report.completed_at || report.updated_at) || new Date().toISOString();
203
+ items.push({
204
+ id: `handoff:${sessionId}:${specId || sceneId || 'project'}`,
205
+ kind: 'handoff',
206
+ state: normalizeString(report.status) || 'handoff',
207
+ ...(sceneId ? { sceneId } : {}),
208
+ ...(specId ? { specId } : {}),
209
+ requestId: sessionId,
210
+ updatedAt,
211
+ summary: `Handoff session ${sessionId} is recorded for ${specId || sceneId || 'project scope'}.`
212
+ });
213
+ }
214
+ return items;
215
+ }
216
+
217
+ async function buildBlockedItems(projectRoot, fileSystem = fs) {
218
+ const files = await listJsonFiles(path.join(projectRoot, STUDIO_REPORT_DIR), fileSystem);
219
+ const items = [];
220
+ for (const filePath of files) {
221
+ const report = await readJsonIfExists(filePath, fileSystem);
222
+ if (!report || typeof report !== 'object' || report.passed !== false) {
223
+ continue;
224
+ }
225
+ const sceneId = resolveReportSceneId(report) || null;
226
+ const specId = resolveReportSpecId(report) || null;
227
+ const reportId = path.basename(filePath, '.json');
228
+ const updatedAt = safeIsoAt(report.completed_at || report.updated_at || report.started_at) || new Date().toISOString();
229
+ items.push({
230
+ id: `blocked:${reportId}`,
231
+ kind: 'blocked',
232
+ state: normalizeString(report.mode) || 'blocked',
233
+ ...(sceneId ? { sceneId } : {}),
234
+ ...(specId ? { specId } : {}),
235
+ eventId: reportId,
236
+ updatedAt,
237
+ summary: `Studio report ${reportId} is blocked.`,
238
+ ...(Array.isArray(report.steps) && report.steps.length > 0
239
+ ? {
240
+ reasonCode: normalizeString(report.steps.find((step) => /fail|error|block/i.test(normalizeString(step && step.status)))?.id) || undefined
241
+ }
242
+ : {})
243
+ });
244
+ }
245
+ return items;
246
+ }
247
+
248
+ async function resolveVisibleProject(projectId, dependencies = {}) {
249
+ const portfolio = await buildProjectPortfolioProjection({}, dependencies);
250
+ const record = (portfolio.projects || []).find((item) => item.projectId === projectId) || null;
251
+ return {
252
+ portfolio,
253
+ record
254
+ };
255
+ }
256
+
257
+ async function buildProjectSupervisionProjection(options = {}, dependencies = {}) {
258
+ const projectId = normalizeString(options.project);
259
+ if (!projectId) {
260
+ throw new Error('--project is required');
261
+ }
262
+
263
+ const fileSystem = dependencies.fileSystem || fs;
264
+ const taskClaimer = dependencies.taskClaimer || new TaskClaimer();
265
+ const { record } = await resolveVisibleProject(projectId, dependencies);
266
+ if (!record) {
267
+ throw new Error(`project not visible: ${projectId}`);
268
+ }
269
+
270
+ if (record.availability === 'inaccessible') {
271
+ const generatedAt = new Date().toISOString();
272
+ return {
273
+ generatedAt,
274
+ projectId,
275
+ cursor: createCursor(projectId, generatedAt, 0),
276
+ summary: {
277
+ blockedCount: 0,
278
+ handoffCount: 0,
279
+ riskCount: 0
280
+ },
281
+ items: [],
282
+ partial: true,
283
+ partialReasons: ['project_inaccessible']
284
+ };
285
+ }
286
+
287
+ const projectRoot = record.projectRoot;
288
+ const sessionStore = new SessionStore(projectRoot, null, {
289
+ fileSystem,
290
+ env: dependencies.env || process.env,
291
+ sqliteModule: dependencies.sqliteModule
292
+ });
293
+ const sceneRecords = await sessionStore.listSceneRecords();
294
+ const activeItems = await buildActiveItems(projectRoot, sceneRecords);
295
+ const riskItems = await buildRiskItems(projectRoot, fileSystem);
296
+ const handoffItems = await buildHandoffItems(projectRoot, fileSystem);
297
+ const blockedItems = await buildBlockedItems(projectRoot, fileSystem);
298
+ const taskSummary = await collectSpecTaskSummary(projectRoot, fileSystem, taskClaimer);
299
+ const items = [
300
+ ...blockedItems,
301
+ ...handoffItems,
302
+ ...riskItems,
303
+ ...activeItems
304
+ ].sort((left, right) => `${right.updatedAt || ''}`.localeCompare(`${left.updatedAt || ''}`));
305
+ const latestEventAt = collectLatestTimestamp(items);
306
+ const generatedAt = new Date().toISOString();
307
+
308
+ return {
309
+ generatedAt,
310
+ projectId,
311
+ cursor: createCursor(projectId, latestEventAt || generatedAt, items.length),
312
+ summary: {
313
+ blockedCount: blockedItems.length,
314
+ handoffCount: handoffItems.length,
315
+ riskCount: riskItems.length,
316
+ activeSceneCount: activeItems.length,
317
+ activeSpecCount: taskSummary.activeSpecCount,
318
+ activeTaskCount: taskSummary.activeTaskCount,
319
+ ...(latestEventAt ? { latestEventAt } : {})
320
+ },
321
+ items,
322
+ partial: record.partial === true,
323
+ partialReasons: Array.isArray(record.partialReasons) ? record.partialReasons : []
324
+ };
325
+ }
326
+
327
+ module.exports = {
328
+ buildProjectSupervisionProjection
329
+ };