scene-capability-engine 3.6.56 → 3.6.58

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 (41) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +6 -3
  3. package/README.zh.md +6 -3
  4. package/bin/scene-capability-engine.js +2 -0
  5. package/docs/autonomous-control-guide.md +2 -0
  6. package/docs/command-reference.md +76 -0
  7. package/docs/magicball-adaptation-task-checklist-v1.md +65 -10
  8. package/docs/magicball-cli-invocation-examples.md +53 -8
  9. package/docs/magicball-engineering-projection-contract.md +175 -0
  10. package/docs/magicball-frontend-state-and-command-mapping.md +42 -5
  11. package/docs/magicball-integration-doc-index.md +19 -5
  12. package/docs/magicball-integration-issue-tracker.md +15 -5
  13. package/docs/magicball-mode-home-and-ontology-empty-state-playbook.md +13 -5
  14. package/docs/magicball-project-portfolio-contract.md +216 -0
  15. package/docs/magicball-sce-adaptation-guide.md +18 -4
  16. package/docs/magicball-ui-surface-checklist.md +25 -0
  17. package/docs/magicball-write-auth-adaptation-guide.md +3 -1
  18. package/docs/release-checklist.md +8 -0
  19. package/docs/releases/README.md +2 -0
  20. package/docs/releases/v3.6.57.md +19 -0
  21. package/docs/releases/v3.6.58.md +27 -0
  22. package/docs/spec-workflow.md +2 -0
  23. package/docs/zh/release-checklist.md +8 -0
  24. package/docs/zh/releases/README.md +2 -0
  25. package/docs/zh/releases/v3.6.57.md +19 -0
  26. package/docs/zh/releases/v3.6.58.md +27 -0
  27. package/lib/app/engineering-scaffold-service.js +154 -0
  28. package/lib/commands/app.js +442 -13
  29. package/lib/commands/project.js +105 -0
  30. package/lib/commands/scene.js +16 -0
  31. package/lib/commands/spec-gate.js +57 -6
  32. package/lib/commands/spec-pipeline.js +13 -4
  33. package/lib/problem/project-problem-projection.js +43 -0
  34. package/lib/project/portfolio-projection-service.js +389 -0
  35. package/lib/project/supervision-projection-service.js +329 -0
  36. package/lib/project/target-resolution-service.js +180 -0
  37. package/lib/scene/delivery-projection-service.js +650 -0
  38. package/package.json +6 -2
  39. package/scripts/magicball-engineering-contract-audit.js +347 -0
  40. package/scripts/magicball-project-contract-audit.js +254 -0
  41. package/template/.sce/README.md +2 -2
@@ -15,9 +15,11 @@ const { ResultEmitter } = require('../spec-gate/result-emitter');
15
15
  const { SessionStore } = require('../runtime/session-store');
16
16
  const { resolveSpecSceneBinding } = require('../runtime/scene-session-binding');
17
17
  const { bindMultiSpecSceneSession } = require('../runtime/multi-spec-scene-session');
18
+ const { assessComplexityStrategy } = require('../spec/complexity-strategy');
18
19
 
19
20
  async function runSpecGate(options = {}, dependencies = {}) {
20
21
  const projectPath = dependencies.projectPath || process.cwd();
22
+ const fileSystem = dependencies.fileSystem || fs;
21
23
  const sessionStore = dependencies.sessionStore || new SessionStore(projectPath);
22
24
  const specTargets = parseSpecTargets(options);
23
25
 
@@ -43,7 +45,7 @@ async function runSpecGate(options = {}, dependencies = {}) {
43
45
  })
44
46
  }, {
45
47
  projectPath,
46
- fileSystem: dependencies.fileSystem || fs,
48
+ fileSystem,
47
49
  sessionStore
48
50
  });
49
51
  }
@@ -51,7 +53,7 @@ async function runSpecGate(options = {}, dependencies = {}) {
51
53
  const specId = specTargets[0];
52
54
 
53
55
  const specPath = path.join(projectPath, '.sce', 'specs', specId);
54
- if (!await fs.pathExists(specPath)) {
56
+ if (!await fileSystem.pathExists(specPath)) {
55
57
  throw new Error(`Spec not found: ${specId}`);
56
58
  }
57
59
 
@@ -60,7 +62,7 @@ async function runSpecGate(options = {}, dependencies = {}) {
60
62
  allowNoScene: false
61
63
  }, {
62
64
  projectPath,
63
- fileSystem: dependencies.fileSystem || fs,
65
+ fileSystem,
64
66
  sessionStore
65
67
  });
66
68
  const linked = await sessionStore.startSpecSession({
@@ -83,13 +85,22 @@ async function runSpecGate(options = {}, dependencies = {}) {
83
85
  policy
84
86
  });
85
87
 
86
- const result = await engine.evaluate({ specId });
88
+ const gateResult = await engine.evaluate({ specId });
89
+ const result = {
90
+ ...gateResult,
91
+ strategy_assessment: await buildStrategyAssessment(specId, {
92
+ projectPath,
93
+ fileSystem,
94
+ strategyAssessor: dependencies.strategyAssessor
95
+ })
96
+ };
87
97
  const emitter = dependencies.emitter || new ResultEmitter(projectPath);
88
98
  const emitted = await emitter.emit(result, {
89
99
  json: options.json,
90
100
  out: options.out,
91
101
  silent: options.silent
92
102
  });
103
+ emitStrategyAdvisory(result.strategy_assessment, options);
93
104
 
94
105
  const decisionStatus = result.decision === 'no-go' ? 'failed' : 'completed';
95
106
  await sessionStore.completeSpecSession({
@@ -101,7 +112,8 @@ async function runSpecGate(options = {}, dependencies = {}) {
101
112
  spec: specId,
102
113
  decision: result.decision,
103
114
  score: result.score,
104
- report_path: emitted.outputPath || null
115
+ report_path: emitted.outputPath || null,
116
+ strategy_decision: result.strategy_assessment ? result.strategy_assessment.decision : null
105
117
  }
106
118
  });
107
119
 
@@ -132,6 +144,43 @@ async function runSpecGate(options = {}, dependencies = {}) {
132
144
  }
133
145
  }
134
146
 
147
+ async function buildStrategyAssessment(specId, dependencies = {}) {
148
+ const assess = dependencies.strategyAssessor || assessComplexityStrategy;
149
+ try {
150
+ return await assess({
151
+ spec: specId
152
+ }, {
153
+ projectPath: dependencies.projectPath,
154
+ fileSystem: dependencies.fileSystem
155
+ });
156
+ } catch (error) {
157
+ return {
158
+ decision: 'assessment-unavailable',
159
+ decision_reason: error.message,
160
+ advisory_only: true
161
+ };
162
+ }
163
+ }
164
+
165
+ function emitStrategyAdvisory(strategyAssessment, options = {}) {
166
+ if (!strategyAssessment || options.json || options.silent) {
167
+ return;
168
+ }
169
+
170
+ if (!['multi-spec-program', 'research-program'].includes(strategyAssessment.decision)) {
171
+ return;
172
+ }
173
+
174
+ console.log();
175
+ console.log(chalk.yellow('⚠ Strategy Advisory'));
176
+ console.log(` ${strategyAssessment.decision}: ${strategyAssessment.decision_reason}`);
177
+ if (Array.isArray(strategyAssessment.next_actions)) {
178
+ strategyAssessment.next_actions.forEach(action => {
179
+ console.log(` - ${action}`);
180
+ });
181
+ }
182
+ }
183
+
135
184
  async function generateSpecGatePolicyTemplate(options = {}, dependencies = {}) {
136
185
  const projectPath = dependencies.projectPath || process.cwd();
137
186
  const loader = dependencies.policyLoader || new PolicyLoader(projectPath);
@@ -220,5 +269,7 @@ module.exports = {
220
269
  registerSpecGateCommand,
221
270
  runSpecGate,
222
271
  generateSpecGatePolicyTemplate,
223
- _parseSpecTargets
272
+ _parseSpecTargets,
273
+ buildStrategyAssessment,
274
+ emitStrategyAdvisory
224
275
  };
@@ -13,9 +13,11 @@ const { createDefaultStageAdapters } = require('../spec/pipeline/stage-adapters'
13
13
  const { SessionStore } = require('../runtime/session-store');
14
14
  const { resolveSpecSceneBinding } = require('../runtime/scene-session-binding');
15
15
  const { bindMultiSpecSceneSession } = require('../runtime/multi-spec-scene-session');
16
+ const { buildStrategyAssessment, emitStrategyAdvisory } = require('./spec-gate');
16
17
 
17
18
  async function runSpecPipeline(options = {}, dependencies = {}) {
18
19
  const projectPath = dependencies.projectPath || process.cwd();
20
+ const fileSystem = dependencies.fileSystem || fs;
19
21
  const sessionStore = dependencies.sessionStore || new SessionStore(projectPath);
20
22
  const specTargets = parseSpecTargets(options);
21
23
  if (specTargets.length === 0) {
@@ -40,7 +42,7 @@ async function runSpecPipeline(options = {}, dependencies = {}) {
40
42
  })
41
43
  }, {
42
44
  projectPath,
43
- fileSystem: dependencies.fileSystem || fs,
45
+ fileSystem,
44
46
  sessionStore
45
47
  });
46
48
  }
@@ -48,7 +50,7 @@ async function runSpecPipeline(options = {}, dependencies = {}) {
48
50
  const specId = specTargets[0];
49
51
 
50
52
  const specPath = path.join(projectPath, '.sce', 'specs', specId);
51
- if (!await fs.pathExists(specPath)) {
53
+ if (!await fileSystem.pathExists(specPath)) {
52
54
  throw new Error(`Spec not found: ${specId}`);
53
55
  }
54
56
 
@@ -57,7 +59,7 @@ async function runSpecPipeline(options = {}, dependencies = {}) {
57
59
  allowNoScene: false
58
60
  }, {
59
61
  projectPath,
60
- fileSystem: dependencies.fileSystem || fs,
62
+ fileSystem,
61
63
  sessionStore
62
64
  });
63
65
 
@@ -130,6 +132,11 @@ async function runSpecPipeline(options = {}, dependencies = {}) {
130
132
  status: execution.status,
131
133
  stage_results: execution.stageResults,
132
134
  failure: execution.failure,
135
+ strategy_assessment: await buildStrategyAssessment(specId, {
136
+ projectPath,
137
+ fileSystem,
138
+ strategyAssessor: dependencies.strategyAssessor
139
+ }),
133
140
  next_actions: buildNextActions(execution),
134
141
  state_file: path.relative(projectPath, stateStore.getRunPath(specId, state.run_id)),
135
142
  scene_session: sceneBinding
@@ -156,7 +163,8 @@ async function runSpecPipeline(options = {}, dependencies = {}) {
156
163
  spec: specId,
157
164
  run_id: state.run_id,
158
165
  pipeline_status: execution.status,
159
- failure: execution.failure || null
166
+ failure: execution.failure || null,
167
+ strategy_decision: result.strategy_assessment ? result.strategy_assessment.decision : null
160
168
  }
161
169
  });
162
170
  }
@@ -174,6 +182,7 @@ async function runSpecPipeline(options = {}, dependencies = {}) {
174
182
  console.log(JSON.stringify(result, null, 2));
175
183
  } else {
176
184
  printResult(result);
185
+ emitStrategyAdvisory(result.strategy_assessment, options);
177
186
  }
178
187
 
179
188
  return result;
@@ -82,6 +82,31 @@ function toRelativePosix(projectPath, absolutePath) {
82
82
  return path.relative(projectPath, absolutePath).replace(/\\/g, '/');
83
83
  }
84
84
 
85
+ function sortKeysDeep(value) {
86
+ if (Array.isArray(value)) {
87
+ return value.map((item) => sortKeysDeep(item));
88
+ }
89
+ if (!value || typeof value !== 'object') {
90
+ return value;
91
+ }
92
+ const sorted = {};
93
+ Object.keys(value).sort().forEach((key) => {
94
+ sorted[key] = sortKeysDeep(value[key]);
95
+ });
96
+ return sorted;
97
+ }
98
+
99
+ function toComparableProjection(payload) {
100
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
101
+ return null;
102
+ }
103
+ const clone = {
104
+ ...payload
105
+ };
106
+ delete clone.generated_at;
107
+ return sortKeysDeep(clone);
108
+ }
109
+
85
110
  async function buildProjectSharedProblemProjection(projectPath = process.cwd(), options = {}, dependencies = {}) {
86
111
  const fileSystem = dependencies.fileSystem || fs;
87
112
  const studioIntakePolicy = dependencies.studioIntakePolicy || await loadStudioIntakePolicy(projectPath, fileSystem);
@@ -212,6 +237,24 @@ async function syncProjectSharedProblemProjection(projectPath = process.cwd(), o
212
237
  ...dependencies,
213
238
  problemClosurePolicyBundle: closurePolicy
214
239
  });
240
+ const existing = await readJsonSafe(absolutePath, fileSystem);
241
+ const existingComparable = toComparableProjection(existing);
242
+ const nextComparable = toComparableProjection(payload);
243
+
244
+ if (
245
+ existingComparable
246
+ && nextComparable
247
+ && JSON.stringify(existingComparable) === JSON.stringify(nextComparable)
248
+ ) {
249
+ return {
250
+ mode: 'project-problem-projection-sync',
251
+ enabled: true,
252
+ file: absolutePath,
253
+ scope: projectionConfig.scope,
254
+ total_entries: Number(payload.summary.total_entries || payload.entries.length || 0),
255
+ refreshed: false
256
+ };
257
+ }
215
258
 
216
259
  await fileSystem.ensureDir(path.dirname(absolutePath));
217
260
  await fileSystem.writeJson(absolutePath, payload, { spaces: 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
+ };