scene-capability-engine 3.6.64 → 3.6.67

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 (125) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +17 -6
  3. package/README.zh.md +18 -6
  4. package/bin/scene-capability-engine.js +4 -0
  5. package/docs/README.md +2 -2
  6. package/docs/command-reference.md +385 -8
  7. package/docs/document-governance.md +3 -2
  8. package/docs/integration-modes.md +62 -478
  9. package/docs/integration-philosophy.md +56 -263
  10. package/docs/magicball-cli-invocation-examples.md +1 -0
  11. package/docs/magicball-project-portfolio-contract.md +125 -4
  12. package/docs/project-management/README.md +14 -0
  13. package/docs/project-management/assurance/backup.md +3 -0
  14. package/docs/project-management/assurance/config.md +3 -0
  15. package/docs/project-management/assurance/evidence/README.md +3 -0
  16. package/docs/project-management/assurance/incidents/README.md +3 -0
  17. package/docs/project-management/assurance/logs.md +3 -0
  18. package/docs/project-management/assurance/overview.md +3 -0
  19. package/docs/project-management/assurance/recovery/README.md +3 -0
  20. package/docs/project-management/assurance/resource.md +3 -0
  21. package/docs/project-management/assurance/runbooks/README.md +3 -0
  22. package/docs/project-management/delivery/acceptance/README.md +3 -0
  23. package/docs/project-management/delivery/acceptance/evidence/README.md +3 -0
  24. package/docs/project-management/delivery/acceptance/exceptions/README.md +3 -0
  25. package/docs/project-management/delivery/acceptance/reports/README.md +3 -0
  26. package/docs/project-management/delivery/documents/changes.md +3 -0
  27. package/docs/project-management/delivery/documents/issues.md +3 -0
  28. package/docs/project-management/delivery/documents/overview.md +3 -0
  29. package/docs/project-management/delivery/documents/planning.md +3 -0
  30. package/docs/project-management/delivery/documents/requirements.md +3 -0
  31. package/docs/project-management/delivery/documents/tracking.md +3 -0
  32. package/docs/project-management/delivery/handoffs/README.md +3 -0
  33. package/docs/project-management/delivery/handoffs/evidence/README.md +3 -0
  34. package/docs/project-management/delivery/handoffs/records/README.md +3 -0
  35. package/docs/project-management/delivery/overview.md +10 -0
  36. package/docs/project-management/delivery/releases/README.md +3 -0
  37. package/docs/project-management/delivery/releases/baselines/README.md +3 -0
  38. package/docs/project-management/delivery/releases/evidence/README.md +3 -0
  39. package/docs/project-management/delivery/tables/changes.md +3 -0
  40. package/docs/project-management/delivery/tables/issues.md +3 -0
  41. package/docs/project-management/delivery/tables/planning.md +3 -0
  42. package/docs/project-management/delivery/tables/requirements.md +3 -0
  43. package/docs/project-management/delivery/tables/tracking.md +3 -0
  44. package/docs/project-management/environment/agent-discovery.md +3 -0
  45. package/docs/project-management/environment/development.md +3 -0
  46. package/docs/project-management/environment/overview.md +10 -0
  47. package/docs/project-management/environment/testing.md +3 -0
  48. package/docs/project-management/environment/version-alignment.md +3 -0
  49. package/docs/quick-start-with-ai-tools.md +68 -308
  50. package/docs/releases/README.md +3 -0
  51. package/docs/releases/v3.6.65.md +25 -0
  52. package/docs/releases/v3.6.66.md +23 -0
  53. package/docs/releases/v3.6.67.md +23 -0
  54. package/docs/steering-governance.md +64 -2
  55. package/docs/zh/README.md +2 -2
  56. package/docs/zh/releases/README.md +3 -0
  57. package/docs/zh/releases/v3.6.65.md +25 -0
  58. package/docs/zh/releases/v3.6.66.md +23 -0
  59. package/docs/zh/releases/v3.6.67.md +23 -0
  60. package/lib/commands/adopt.js +24 -0
  61. package/lib/commands/native.js +158 -0
  62. package/lib/commands/project.js +96 -0
  63. package/lib/commands/semantic.js +1459 -0
  64. package/lib/commands/session.js +74 -3
  65. package/lib/commands/spec-bootstrap.js +10 -1
  66. package/lib/commands/spec-gate.js +10 -1
  67. package/lib/commands/spec-pipeline.js +10 -1
  68. package/lib/commands/studio.js +405 -30
  69. package/lib/commands/task.js +141 -7
  70. package/lib/governance/supreme-principles.js +530 -0
  71. package/lib/problem/problem-evaluator.js +4 -0
  72. package/lib/project/candidate-inspection-service.js +24 -1
  73. package/lib/project/portfolio-projection-service.js +315 -5
  74. package/lib/project/project-channel-output.js +94 -0
  75. package/lib/project/project-channel-projection.js +181 -0
  76. package/lib/project/root-onboarding-service.js +107 -7
  77. package/lib/project/semantic-shared-source-projection.js +150 -0
  78. package/lib/project/supervision-action-model.js +277 -0
  79. package/lib/project/supervision-projection-service.js +305 -5
  80. package/lib/project/target-resolution-service.js +70 -5
  81. package/lib/project/visibility-policy.js +93 -0
  82. package/lib/runtime/multi-spec-scene-session.js +8 -1
  83. package/lib/runtime/project-channel-context-store.js +387 -0
  84. package/lib/runtime/project-channel-context.js +406 -0
  85. package/lib/runtime/scene-session-binding.js +46 -0
  86. package/lib/runtime/session-store.js +186 -0
  87. package/lib/runtime/steering-contract.js +7 -1
  88. package/lib/semantic/archive-report.js +283 -0
  89. package/lib/semantic/archive-routing.js +67 -0
  90. package/lib/semantic/backflow-report.js +245 -0
  91. package/lib/semantic/capability-contract.js +30 -0
  92. package/lib/semantic/delta-export.js +145 -0
  93. package/lib/semantic/interaction-observer.js +254 -0
  94. package/lib/semantic/kernel-loader.js +881 -0
  95. package/lib/semantic/native-runtime.js +359 -0
  96. package/lib/semantic/progress-ledger.js +433 -0
  97. package/lib/semantic/replay-evaluator.js +382 -0
  98. package/lib/semantic/shared-publication.js +592 -0
  99. package/lib/semantic/shared-source-config.js +183 -0
  100. package/lib/semantic/shared-source-connect.js +139 -0
  101. package/lib/semantic/shared-source-discovery.js +98 -0
  102. package/lib/semantic/shared-sync-export.js +413 -0
  103. package/lib/semantic/shared-sync-intake.js +592 -0
  104. package/lib/semantic/shared-sync-merge.js +547 -0
  105. package/lib/semantic/shared-sync-release.js +463 -0
  106. package/lib/semantic/supreme-intent-report.js +300 -0
  107. package/lib/state/sce-state-store.js +1360 -0
  108. package/lib/steering/context-sync-manager.js +276 -25
  109. package/lib/studio/spec-intake-governor.js +39 -3
  110. package/lib/studio/task-envelope.js +35 -2
  111. package/lib/workspace/takeover-baseline.js +342 -83
  112. package/package.json +7 -2
  113. package/scripts/agent-governance-baseline-audit.js +395 -0
  114. package/scripts/clarification-first-audit.js +9 -9
  115. package/scripts/deprecated-entry-audit.js +240 -0
  116. package/scripts/release-doc-version-audit.js +24 -0
  117. package/scripts/release-posture-report.js +262 -0
  118. package/template/.sce/README.md +62 -228
  119. package/template/.sce/config/semantic-shared-sources.json +5 -0
  120. package/template/.sce/config/supreme-principles-policy.json +105 -0
  121. package/template/.sce/config/takeover-baseline.json +7 -0
  122. package/template/.sce/steering/CORE_PRINCIPLES.md +23 -63
  123. package/template/.sce/steering/CURRENT_CONTEXT.md +4 -0
  124. package/template/.sce/steering/RULES_GUIDE.md +17 -9
  125. package/template/README.md +32 -96
@@ -1,5 +1,7 @@
1
1
  const path = require('path');
2
2
  const { buildProjectPortfolioProjection } = require('./portfolio-projection-service');
3
+ const { buildProjectChannelProjection } = require('./project-channel-projection');
4
+ const { buildProjectChannelOutput, buildProjectChannelOutputFromProjection } = require('./project-channel-output');
3
5
 
4
6
  function normalizeString(value) {
5
7
  if (typeof value !== 'string') {
@@ -67,28 +69,71 @@ function scoreProjectMatch(requestText, project = {}) {
67
69
  workspaceId: project.workspaceId || null,
68
70
  projectName: project.projectName || null,
69
71
  appKey: project.appKey || null,
72
+ projectChannelContext: project.projectChannelContext || null,
70
73
  confidence: Number(bestScore.toFixed(2)),
71
74
  reasonCode
72
75
  };
73
76
  }
74
77
 
75
- function buildResolutionCallerContext(options = {}, portfolio = {}) {
78
+ function buildCallerProjectChannelBinding(projectId, projectChannelProjection = {}) {
79
+ const channel = buildProjectChannelOutputFromProjection(projectChannelProjection, {
80
+ canonicalProjectId: projectId
81
+ });
82
+ if (!channel.context_available || !channel.channel_id) {
83
+ return null;
84
+ }
85
+ return channel;
86
+ }
87
+
88
+ async function buildResolutionCallerContext(options = {}, portfolio = {}, dependencies = {}) {
76
89
  const portfolioContext = portfolio && portfolio.callerContext && typeof portfolio.callerContext === 'object'
77
90
  ? portfolio.callerContext
78
91
  : {};
79
92
  const explicitCurrentProject = normalizeString(options.currentProject);
80
93
  const explicitDeviceId = normalizeString(options.device);
81
94
  const explicitToolInstanceId = normalizeString(options.toolInstanceId);
95
+ const explicitChannelId = normalizeString(options.channel);
96
+ const currentProjectId = explicitCurrentProject || normalizeString(portfolioContext.projectId);
97
+ let projectChannelProjection = null;
98
+
99
+ if (currentProjectId) {
100
+ const currentProject = Array.isArray(portfolio.projects)
101
+ ? portfolio.projects.find((project) => project.projectId === currentProjectId)
102
+ : null;
103
+ if (currentProject && normalizeString(currentProject.projectRoot)) {
104
+ projectChannelProjection = await buildProjectChannelProjection(currentProject.projectRoot, {
105
+ preferredProjectIds: [
106
+ currentProject.projectChannelContext && currentProject.projectChannelContext.contextProjectId,
107
+ currentProjectId,
108
+ currentProject.workspaceId
109
+ ],
110
+ channelId: explicitChannelId
111
+ }, dependencies);
112
+ }
113
+ }
114
+
115
+ const callerProjectChannel = buildCallerProjectChannelBinding(currentProjectId, projectChannelProjection || {});
82
116
 
83
117
  return {
84
- ...(explicitCurrentProject || portfolioContext.projectId
85
- ? { currentProjectId: explicitCurrentProject || portfolioContext.projectId }
118
+ ...(currentProjectId
119
+ ? { currentProjectId }
86
120
  : {}),
87
121
  ...(portfolioContext.workspaceId ? { workspaceId: portfolioContext.workspaceId } : {}),
88
122
  ...(explicitDeviceId || portfolioContext.deviceId
89
123
  ? { deviceId: explicitDeviceId || portfolioContext.deviceId }
90
124
  : {}),
91
- ...(explicitToolInstanceId ? { toolInstanceId: explicitToolInstanceId } : {})
125
+ ...(explicitToolInstanceId ? { toolInstanceId: explicitToolInstanceId } : {}),
126
+ ...(callerProjectChannel && callerProjectChannel.channel_id
127
+ ? {
128
+ currentChannelId: callerProjectChannel.channel_id,
129
+ currentChannelContextAvailable: callerProjectChannel.context_available,
130
+ currentChannelStorageMode: callerProjectChannel.storage_mode,
131
+ requestedChannelId: callerProjectChannel.requested_channel_id,
132
+ focusedChannelId: callerProjectChannel.focused_channel_id,
133
+ activeScene: callerProjectChannel.active_scene,
134
+ activeSpecId: callerProjectChannel.active_spec_id
135
+ }
136
+ : {})
92
137
  };
93
138
  }
94
139
 
@@ -97,16 +142,32 @@ async function resolveProjectTarget(options = {}, dependencies = {}) {
97
142
  const portfolio = await buildProjectPortfolioProjection({
98
143
  workspace: options.workspace
99
144
  }, dependencies);
100
- const callerContext = buildResolutionCallerContext(options, portfolio);
145
+ const callerContext = await buildResolutionCallerContext(options, portfolio, dependencies);
101
146
  const currentProjectId = normalizeString(callerContext.currentProjectId);
102
147
  const visibleProjects = Array.isArray(portfolio.projects) ? portfolio.projects : [];
103
148
  const currentProject = visibleProjects.find((project) => project.projectId === currentProjectId) || null;
149
+ const callerProjectChannel = callerContext.currentChannelId
150
+ ? buildProjectChannelOutput({
151
+ projectId: currentProjectId || null,
152
+ canonicalProjectId: currentProjectId || null,
153
+ requestedChannelId: callerContext.requestedChannelId,
154
+ contextAvailable: callerContext.currentChannelContextAvailable === true,
155
+ storageMode: callerContext.currentChannelStorageMode,
156
+ fallback: {
157
+ focused_channel_id: callerContext.focusedChannelId,
158
+ channel_id: callerContext.currentChannelId,
159
+ active_scene: callerContext.activeScene,
160
+ active_spec_id: callerContext.activeSpecId
161
+ }
162
+ })
163
+ : null;
104
164
 
105
165
  if (!requestText) {
106
166
  if (currentProject) {
107
167
  return {
108
168
  resolvedAt: new Date().toISOString(),
109
169
  callerContext,
170
+ ...(callerProjectChannel ? { callerProjectChannel } : {}),
110
171
  status: 'current-project',
111
172
  currentProjectId,
112
173
  resolvedProjectId: currentProjectId,
@@ -117,6 +178,7 @@ async function resolveProjectTarget(options = {}, dependencies = {}) {
117
178
  return {
118
179
  resolvedAt: new Date().toISOString(),
119
180
  callerContext,
181
+ ...(callerProjectChannel ? { callerProjectChannel } : {}),
120
182
  status: 'unresolved',
121
183
  ...(currentProjectId ? { currentProjectId } : {}),
122
184
  reasonCode: currentProjectId
@@ -139,6 +201,7 @@ async function resolveProjectTarget(options = {}, dependencies = {}) {
139
201
  return {
140
202
  resolvedAt: new Date().toISOString(),
141
203
  callerContext,
204
+ ...(callerProjectChannel ? { callerProjectChannel } : {}),
142
205
  status: 'unresolved',
143
206
  ...(currentProjectId ? { currentProjectId } : {}),
144
207
  reasonCode: 'target.no_match'
@@ -153,6 +216,7 @@ async function resolveProjectTarget(options = {}, dependencies = {}) {
153
216
  return {
154
217
  resolvedAt: new Date().toISOString(),
155
218
  callerContext,
219
+ ...(callerProjectChannel ? { callerProjectChannel } : {}),
156
220
  status: 'ambiguous',
157
221
  ...(currentProjectId ? { currentProjectId } : {}),
158
222
  confidence: best.confidence,
@@ -164,6 +228,7 @@ async function resolveProjectTarget(options = {}, dependencies = {}) {
164
228
  return {
165
229
  resolvedAt: new Date().toISOString(),
166
230
  callerContext,
231
+ ...(callerProjectChannel ? { callerProjectChannel } : {}),
167
232
  status: currentProjectId && best.projectId === currentProjectId
168
233
  ? 'current-project'
169
234
  : 'resolved-other-project',
@@ -0,0 +1,93 @@
1
+ const path = require('path');
2
+
3
+ const PROJECT_VISIBILITY_REASON_CODES = {
4
+ MANAGED: 'project.visibility.managed',
5
+ EPHEMERAL_ROOT: 'project.visibility.ephemeral_root'
6
+ };
7
+
8
+ const EXPLICIT_PROBE_NAME_PATTERNS = [
9
+ /^_codex_import_probe(?:[-_.].*)?$/,
10
+ /^_mb_tmp_project_import_probe(?:[-_.].*)?$/,
11
+ /(?:^|[-_.])codex_import_probe(?:[-_.].*)?$/,
12
+ /(?:^|[-_.])mb_tmp_project_import_probe(?:[-_.].*)?$/,
13
+ /(?:^|[-_.])tmp_project_import_probe(?:[-_.].*)?$/,
14
+ /(?:^|[-_.])import_probe(?:[-_.].*)?$/
15
+ ];
16
+
17
+ const TEMP_PATH_PATTERNS = [
18
+ /\/tmp\//,
19
+ /\/temp\//,
20
+ /\/appdata\/local\/temp\//,
21
+ /\/var\/folders\//
22
+ ];
23
+
24
+ function normalizeString(value) {
25
+ if (typeof value !== 'string') {
26
+ return '';
27
+ }
28
+ return value.trim();
29
+ }
30
+
31
+ function normalizeText(value) {
32
+ return normalizeString(value).toLowerCase();
33
+ }
34
+
35
+ function normalizePath(value) {
36
+ return normalizeString(value).replace(/\\/g, '/');
37
+ }
38
+
39
+ function looksLikeExplicitProbeName(value) {
40
+ const normalized = normalizeText(value);
41
+ if (!normalized) {
42
+ return false;
43
+ }
44
+ return EXPLICIT_PROBE_NAME_PATTERNS.some((pattern) => pattern.test(normalized));
45
+ }
46
+
47
+ function looksLikeTempProbePath(projectRoot) {
48
+ const normalizedRoot = normalizePath(projectRoot).toLowerCase();
49
+ if (!normalizedRoot) {
50
+ return false;
51
+ }
52
+ const baseName = normalizeText(path.basename(normalizedRoot));
53
+ if (!baseName.includes('probe')) {
54
+ return false;
55
+ }
56
+ return TEMP_PATH_PATTERNS.some((pattern) => pattern.test(normalizedRoot));
57
+ }
58
+
59
+ function isEphemeralProjectRoot(input = {}) {
60
+ const workspaceId = normalizeString(input.workspaceId);
61
+ const projectRoot = normalizePath(input.projectRoot || input.rootDir);
62
+ const projectName = normalizeString(input.projectName) || (projectRoot ? path.basename(projectRoot) : '');
63
+ return looksLikeExplicitProbeName(workspaceId)
64
+ || looksLikeExplicitProbeName(projectName)
65
+ || looksLikeTempProbePath(projectRoot);
66
+ }
67
+
68
+ function classifyProjectVisibility(input = {}) {
69
+ if (isEphemeralProjectRoot(input)) {
70
+ return {
71
+ visibility: 'hidden',
72
+ lifecycle: 'ephemeral',
73
+ reasonCode: PROJECT_VISIBILITY_REASON_CODES.EPHEMERAL_ROOT
74
+ };
75
+ }
76
+
77
+ return {
78
+ visibility: 'managed',
79
+ lifecycle: 'persistent',
80
+ reasonCode: PROJECT_VISIBILITY_REASON_CODES.MANAGED
81
+ };
82
+ }
83
+
84
+ function isCallerVisibleProject(input = {}) {
85
+ return classifyProjectVisibility(input).visibility === 'managed';
86
+ }
87
+
88
+ module.exports = {
89
+ PROJECT_VISIBILITY_REASON_CODES,
90
+ classifyProjectVisibility,
91
+ isCallerVisibleProject,
92
+ isEphemeralProjectRoot
93
+ };
@@ -40,6 +40,8 @@ async function bindMultiSpecSceneSession(options = {}, dependencies = {}) {
40
40
  const {
41
41
  specTargets = [],
42
42
  sceneId = null,
43
+ projectId = null,
44
+ collabChannel = null,
43
45
  commandName = 'spec-command',
44
46
  commandLabel = 'spec-command',
45
47
  commandOptions = {},
@@ -60,6 +62,8 @@ async function bindMultiSpecSceneSession(options = {}, dependencies = {}) {
60
62
 
61
63
  const sceneBinding = await resolveSpecSceneBinding({
62
64
  sceneId,
65
+ projectId,
66
+ collabChannel,
63
67
  allowNoScene: false
64
68
  }, {
65
69
  projectPath,
@@ -115,7 +119,10 @@ async function bindMultiSpecSceneSession(options = {}, dependencies = {}) {
115
119
  scene_session_id: sceneBinding.scene_session_id,
116
120
  binding_source: sceneBinding.source,
117
121
  multi_spec_child_sessions: specSessions
118
- }
122
+ },
123
+ ...(sceneBinding.project_channel
124
+ ? { project_channel: sceneBinding.project_channel }
125
+ : {})
119
126
  };
120
127
  } catch (error) {
121
128
  if (shouldTrackSessions) {
@@ -0,0 +1,387 @@
1
+ const path = require('path');
2
+ const fs = require('fs-extra');
3
+ const {
4
+ normalizeProjectChannelContext,
5
+ normalizeChannelState,
6
+ setFocusedChannel,
7
+ updateChannelState
8
+ } = require('./project-channel-context');
9
+
10
+ const PROJECT_CHANNEL_CONTEXT_DIR = path.join('.sce', 'state', 'project-channel-contexts');
11
+ const PROJECT_CHANNEL_INDEX_FILE = 'index.json';
12
+ const PROJECT_CHANNEL_CHANNELS_DIR = 'channels';
13
+
14
+ function normalizeString(value) {
15
+ if (typeof value !== 'string') {
16
+ return '';
17
+ }
18
+ return value.trim();
19
+ }
20
+
21
+ function safePathPart(value, fallback) {
22
+ const normalized = `${value || ''}`
23
+ .trim()
24
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
25
+ .replace(/^-+|-+$/g, '');
26
+ return normalized || fallback;
27
+ }
28
+
29
+ class ProjectChannelContextStore {
30
+ constructor(projectPath = process.cwd(), fileSystem = fs, options = {}) {
31
+ this._projectPath = projectPath;
32
+ this._fileSystem = fileSystem;
33
+ this._contextDir = path.join(projectPath, PROJECT_CHANNEL_CONTEXT_DIR);
34
+ this._contextSyncManager = options.contextSyncManager || null;
35
+ }
36
+
37
+ getContextPath(projectId) {
38
+ return this.getProjectIndexPath(projectId);
39
+ }
40
+
41
+ getLegacyContextPath(projectId) {
42
+ const normalizedProjectId = normalizeString(projectId);
43
+ if (!normalizedProjectId) {
44
+ throw new Error('projectId is required');
45
+ }
46
+ return path.join(this._contextDir, `${safePathPart(normalizedProjectId, 'project')}.json`);
47
+ }
48
+
49
+ getProjectDirectoryPath(projectId) {
50
+ const normalizedProjectId = normalizeString(projectId);
51
+ if (!normalizedProjectId) {
52
+ throw new Error('projectId is required');
53
+ }
54
+ return path.join(this._contextDir, safePathPart(normalizedProjectId, 'project'));
55
+ }
56
+
57
+ getProjectIndexPath(projectId) {
58
+ return path.join(this.getProjectDirectoryPath(projectId), PROJECT_CHANNEL_INDEX_FILE);
59
+ }
60
+
61
+ getChannelContextPath(projectId, channelId) {
62
+ const normalizedChannelId = normalizeString(channelId);
63
+ if (!normalizedChannelId) {
64
+ throw new Error('channelId is required');
65
+ }
66
+ return path.join(
67
+ this.getProjectDirectoryPath(projectId),
68
+ PROJECT_CHANNEL_CHANNELS_DIR,
69
+ `${safePathPart(normalizedChannelId, 'channel-default')}.json`
70
+ );
71
+ }
72
+
73
+ async load(projectId, options = {}) {
74
+ const normalizedProjectId = normalizeString(projectId);
75
+ if (!normalizedProjectId) {
76
+ throw new Error('projectId is required');
77
+ }
78
+
79
+ const indexPayload = await this._readProjectIndex(normalizedProjectId);
80
+ const contextPayload = indexPayload
81
+ ? await this._readSplitContext(normalizedProjectId, indexPayload)
82
+ : await this._readLegacyContext(normalizedProjectId);
83
+
84
+ return normalizeProjectChannelContext(contextPayload || { projectId: normalizedProjectId }, {
85
+ projectId: normalizedProjectId,
86
+ defaultChannelId: options.defaultChannelId,
87
+ now: options.now,
88
+ channelDefaults: options.channelDefaults,
89
+ allowDefaultChannelSynthesis: options.allowDefaultChannelSynthesis
90
+ });
91
+ }
92
+
93
+ async hasPersistedContext(projectId) {
94
+ const normalizedProjectId = normalizeString(projectId);
95
+ if (!normalizedProjectId) {
96
+ throw new Error('projectId is required');
97
+ }
98
+
99
+ return (await this.detectPersistedContext(normalizedProjectId)).available;
100
+ }
101
+
102
+ async detectPersistedContext(projectId) {
103
+ const normalizedProjectId = normalizeString(projectId);
104
+ if (!normalizedProjectId) {
105
+ throw new Error('projectId is required');
106
+ }
107
+
108
+ if (await this._fileSystem.pathExists(this.getProjectIndexPath(normalizedProjectId))) {
109
+ return {
110
+ available: true,
111
+ storageMode: 'split'
112
+ };
113
+ }
114
+
115
+ if (await this._fileSystem.pathExists(this.getLegacyContextPath(normalizedProjectId))) {
116
+ return {
117
+ available: true,
118
+ storageMode: 'legacy'
119
+ };
120
+ }
121
+
122
+ return {
123
+ available: false,
124
+ storageMode: 'none'
125
+ };
126
+ }
127
+
128
+ async listPersistedContexts() {
129
+ if (!await this._fileSystem.pathExists(this._contextDir)) {
130
+ return [];
131
+ }
132
+
133
+ let entries = [];
134
+ try {
135
+ entries = await this._fileSystem.readdir(this._contextDir);
136
+ } catch (_error) {
137
+ return [];
138
+ }
139
+
140
+ const contexts = new Map();
141
+ for (const entry of entries) {
142
+ const candidatePath = path.join(this._contextDir, entry);
143
+ let stats = null;
144
+ try {
145
+ stats = await this._fileSystem.stat(candidatePath);
146
+ } catch (_error) {
147
+ continue;
148
+ }
149
+
150
+ if (stats.isDirectory()) {
151
+ const indexPath = path.join(candidatePath, PROJECT_CHANNEL_INDEX_FILE);
152
+ if (!await this._fileSystem.pathExists(indexPath)) {
153
+ continue;
154
+ }
155
+ try {
156
+ const payload = await this._fileSystem.readJson(indexPath);
157
+ const projectId = normalizeString(payload && payload.projectId);
158
+ if (projectId) {
159
+ contexts.set(projectId, {
160
+ projectId,
161
+ storageMode: 'split'
162
+ });
163
+ }
164
+ } catch (_error) {
165
+ // Ignore unreadable split contexts and let projection report partial elsewhere.
166
+ }
167
+ continue;
168
+ }
169
+
170
+ if (!stats.isFile() || path.extname(entry).toLowerCase() !== '.json') {
171
+ continue;
172
+ }
173
+
174
+ try {
175
+ const payload = await this._fileSystem.readJson(candidatePath);
176
+ const projectId = normalizeString(payload && payload.projectId);
177
+ if (projectId && !contexts.has(projectId)) {
178
+ contexts.set(projectId, {
179
+ projectId,
180
+ storageMode: 'legacy'
181
+ });
182
+ }
183
+ } catch (_error) {
184
+ // Ignore unreadable legacy contexts and let projection report partial elsewhere.
185
+ }
186
+ }
187
+
188
+ return [...contexts.values()].sort((left, right) => left.projectId.localeCompare(right.projectId));
189
+ }
190
+
191
+ async save(context = {}, options = {}) {
192
+ const normalized = normalizeProjectChannelContext(context, {
193
+ projectId: options.projectId,
194
+ defaultChannelId: options.defaultChannelId,
195
+ now: options.now,
196
+ channelDefaults: options.channelDefaults
197
+ });
198
+
199
+ await this._writeFullContext(normalized);
200
+ await this._cleanupLegacyContext(normalized.projectId);
201
+
202
+ if (options.syncCurrentContext === true || this._contextSyncManager) {
203
+ await this.syncCurrentContext(normalized.projectId, {
204
+ ...options,
205
+ projectChannelContext: normalized
206
+ });
207
+ }
208
+
209
+ return normalized;
210
+ }
211
+
212
+ async updateChannel(projectId, channelId, patch = {}, options = {}) {
213
+ const current = await this.load(projectId, options);
214
+ const next = updateChannelState(current, channelId, patch, options);
215
+
216
+ await this._writeProjectShell(next.projectId, next.focusedChannelId, Object.keys(next.channels));
217
+ await this._writeChannelState(next.projectId, next.channels[channelId]);
218
+ await this._cleanupLegacyContext(next.projectId);
219
+
220
+ if (options.syncCurrentContext === true || this._contextSyncManager) {
221
+ await this.syncCurrentContext(next.projectId, {
222
+ ...options,
223
+ projectChannelContext: next
224
+ });
225
+ }
226
+
227
+ return next;
228
+ }
229
+
230
+ async focusChannel(projectId, channelId, options = {}) {
231
+ const current = await this.load(projectId, options);
232
+ const next = setFocusedChannel(current, channelId, options);
233
+
234
+ await this._writeProjectShell(next.projectId, next.focusedChannelId, Object.keys(next.channels));
235
+ if (!current.channels[channelId] && next.channels[channelId]) {
236
+ await this._writeChannelState(next.projectId, next.channels[channelId]);
237
+ }
238
+ await this._cleanupLegacyContext(next.projectId);
239
+
240
+ if (options.syncCurrentContext === true || this._contextSyncManager) {
241
+ await this.syncCurrentContext(next.projectId, {
242
+ ...options,
243
+ projectChannelContext: next
244
+ });
245
+ }
246
+
247
+ return next;
248
+ }
249
+
250
+ async syncCurrentContext(projectId, options = {}) {
251
+ const manager = options.contextSyncManager
252
+ || this._contextSyncManager
253
+ || this._createContextSyncManager();
254
+ const projectChannelContext = options.projectChannelContext || await this.load(projectId, options);
255
+ await manager.updateProjectChannelSummary(projectId, projectChannelContext);
256
+ return { success: true };
257
+ }
258
+
259
+ async _readProjectIndex(projectId) {
260
+ const indexPath = this.getProjectIndexPath(projectId);
261
+ if (!await this._fileSystem.pathExists(indexPath)) {
262
+ return null;
263
+ }
264
+
265
+ try {
266
+ return await this._fileSystem.readJson(indexPath);
267
+ } catch (_error) {
268
+ return null;
269
+ }
270
+ }
271
+
272
+ async _readSplitContext(projectId, indexPayload = {}) {
273
+ const normalizedProjectId = normalizeString(projectId);
274
+ const indexChannels = Array.isArray(indexPayload.channelIds)
275
+ ? indexPayload.channelIds
276
+ : [];
277
+ const channels = {};
278
+
279
+ for (const channelId of indexChannels) {
280
+ const normalizedChannelId = normalizeString(channelId);
281
+ if (!normalizedChannelId) {
282
+ continue;
283
+ }
284
+
285
+ const channelPath = this.getChannelContextPath(normalizedProjectId, normalizedChannelId);
286
+ try {
287
+ const rawChannel = await this._fileSystem.readJson(channelPath);
288
+ const channelState = normalizeChannelState(normalizedChannelId, rawChannel, {
289
+ projectId: normalizedProjectId
290
+ });
291
+ channels[channelState.channelId] = channelState;
292
+ } catch (_error) {
293
+ // ignore unreadable channel file and let normalizeProjectChannelContext repair focus/defaults
294
+ }
295
+ }
296
+
297
+ return {
298
+ projectId: normalizedProjectId,
299
+ focusedChannelId: normalizeString(indexPayload.focusedChannelId),
300
+ channels
301
+ };
302
+ }
303
+
304
+ async _readLegacyContext(projectId) {
305
+ const legacyPath = this.getLegacyContextPath(projectId);
306
+ if (!await this._fileSystem.pathExists(legacyPath)) {
307
+ return null;
308
+ }
309
+
310
+ try {
311
+ return await this._fileSystem.readJson(legacyPath);
312
+ } catch (_error) {
313
+ return null;
314
+ }
315
+ }
316
+
317
+ async _writeFullContext(context) {
318
+ const channelIds = Object.keys(context.channels);
319
+ await this._writeProjectShell(context.projectId, context.focusedChannelId, channelIds);
320
+
321
+ for (const channelId of channelIds) {
322
+ await this._writeChannelState(context.projectId, context.channels[channelId]);
323
+ }
324
+
325
+ await this._deleteRemovedChannelFiles(context.projectId, channelIds);
326
+ }
327
+
328
+ async _writeProjectShell(projectId, focusedChannelId, channelIds) {
329
+ const indexPath = this.getProjectIndexPath(projectId);
330
+ await this._fileSystem.ensureDir(path.dirname(indexPath));
331
+ await this._fileSystem.writeJson(indexPath, {
332
+ projectId,
333
+ focusedChannelId,
334
+ channelIds: [...channelIds].sort((left, right) => left.localeCompare(right))
335
+ }, { spaces: 2 });
336
+ }
337
+
338
+ async _writeChannelState(projectId, channelState) {
339
+ const normalizedChannelId = normalizeString(channelState && channelState.channelId);
340
+ if (!normalizedChannelId) {
341
+ throw new Error('channelState.channelId is required');
342
+ }
343
+ const channelPath = this.getChannelContextPath(projectId, normalizedChannelId);
344
+ await this._fileSystem.ensureDir(path.dirname(channelPath));
345
+ await this._fileSystem.writeJson(channelPath, channelState, { spaces: 2 });
346
+ }
347
+
348
+ async _deleteRemovedChannelFiles(projectId, retainedChannelIds = []) {
349
+ const channelsDir = path.join(this.getProjectDirectoryPath(projectId), PROJECT_CHANNEL_CHANNELS_DIR);
350
+ if (!await this._fileSystem.pathExists(channelsDir)) {
351
+ return;
352
+ }
353
+
354
+ const retained = new Set(
355
+ retainedChannelIds.map((channelId) => this.getChannelContextPath(projectId, channelId))
356
+ );
357
+ const entries = await this._fileSystem.readdir(channelsDir);
358
+ for (const entry of entries) {
359
+ if (!entry.endsWith('.json')) {
360
+ continue;
361
+ }
362
+ const candidatePath = path.join(channelsDir, entry);
363
+ if (!retained.has(candidatePath)) {
364
+ await this._fileSystem.remove(candidatePath);
365
+ }
366
+ }
367
+ }
368
+
369
+ async _cleanupLegacyContext(projectId) {
370
+ const legacyPath = this.getLegacyContextPath(projectId);
371
+ if (await this._fileSystem.pathExists(legacyPath)) {
372
+ await this._fileSystem.remove(legacyPath);
373
+ }
374
+ }
375
+
376
+ _createContextSyncManager() {
377
+ const { ContextSyncManager } = require('../steering/context-sync-manager');
378
+ return new ContextSyncManager(this._projectPath);
379
+ }
380
+ }
381
+
382
+ module.exports = {
383
+ ProjectChannelContextStore,
384
+ PROJECT_CHANNEL_CONTEXT_DIR,
385
+ PROJECT_CHANNEL_INDEX_FILE,
386
+ PROJECT_CHANNEL_CHANNELS_DIR
387
+ };