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.
- package/CHANGELOG.md +18 -0
- package/README.md +5 -3
- package/README.zh.md +5 -3
- package/bin/scene-capability-engine.js +2 -0
- package/docs/command-reference.md +72 -0
- package/docs/magicball-adaptation-task-checklist-v1.md +65 -10
- package/docs/magicball-cli-invocation-examples.md +53 -8
- package/docs/magicball-engineering-projection-contract.md +175 -0
- package/docs/magicball-frontend-state-and-command-mapping.md +42 -5
- package/docs/magicball-integration-doc-index.md +19 -5
- package/docs/magicball-integration-issue-tracker.md +15 -5
- package/docs/magicball-mode-home-and-ontology-empty-state-playbook.md +13 -5
- package/docs/magicball-project-portfolio-contract.md +216 -0
- package/docs/magicball-sce-adaptation-guide.md +18 -4
- package/docs/magicball-ui-surface-checklist.md +25 -0
- package/docs/magicball-write-auth-adaptation-guide.md +3 -1
- package/docs/release-checklist.md +8 -0
- package/docs/releases/README.md +2 -0
- package/docs/releases/v3.6.58.md +27 -0
- package/docs/releases/v3.6.59.md +18 -0
- package/docs/zh/release-checklist.md +8 -0
- package/docs/zh/releases/README.md +2 -0
- package/docs/zh/releases/v3.6.58.md +27 -0
- package/docs/zh/releases/v3.6.59.md +18 -0
- package/lib/app/engineering-scaffold-service.js +154 -0
- package/lib/commands/app.js +442 -13
- package/lib/commands/project.js +105 -0
- package/lib/commands/scene.js +16 -0
- package/lib/project/portfolio-projection-service.js +389 -0
- package/lib/project/supervision-projection-service.js +329 -0
- package/lib/project/target-resolution-service.js +180 -0
- package/lib/scene/delivery-projection-service.js +650 -0
- package/package.json +6 -2
- package/scripts/magicball-engineering-contract-audit.js +347 -0
- package/scripts/magicball-project-contract-audit.js +254 -0
- 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
|
+
};
|