@sanqianx/project-knowledge 4.6.1 → 4.8.0

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 (198) hide show
  1. package/.agents/plugins/marketplace.json +17 -17
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/CHANGELOG.md +817 -780
  4. package/README.md +48 -36
  5. package/_modules/knowledge-engine/README.md +88 -0
  6. package/_modules/knowledge-engine/package.json +38 -0
  7. package/_modules/knowledge-engine/src/analysis-contract.js +25 -0
  8. package/_modules/knowledge-engine/src/analysis-worker.js +54 -0
  9. package/_modules/knowledge-engine/src/bin.js +52 -0
  10. package/_modules/knowledge-engine/src/bridge-client.js +24 -0
  11. package/_modules/knowledge-engine/src/coordinator.js +255 -0
  12. package/_modules/knowledge-engine/src/headless-analyzer.js +75 -0
  13. package/_modules/knowledge-engine/src/memories.js +85 -0
  14. package/_modules/knowledge-engine/src/migration.js +88 -0
  15. package/_modules/knowledge-engine/src/retrieval.js +116 -0
  16. package/_modules/knowledge-engine/src/runtime/README.md +7 -0
  17. package/_modules/knowledge-engine/src/runtime/atomic-file.js +204 -0
  18. package/_modules/knowledge-engine/src/runtime/automation-config.js +140 -0
  19. package/_modules/knowledge-engine/src/runtime/commit-conversation-binder.js +249 -0
  20. package/_modules/knowledge-engine/src/runtime/commit-processing-ledger.js +26 -0
  21. package/_modules/knowledge-engine/src/runtime/commit-prompt.js +202 -0
  22. package/_modules/knowledge-engine/src/runtime/commit-reconciler.js +432 -0
  23. package/_modules/knowledge-engine/src/runtime/content-hash.js +4 -0
  24. package/_modules/knowledge-engine/src/runtime/contracts.js +279 -0
  25. package/_modules/knowledge-engine/src/runtime/conversation-exclusions.js +204 -0
  26. package/_modules/knowledge-engine/src/runtime/conversation-store.js +387 -0
  27. package/_modules/knowledge-engine/src/runtime/evidence-bundle.js +228 -0
  28. package/_modules/knowledge-engine/src/runtime/git-runner.js +62 -0
  29. package/_modules/knowledge-engine/src/runtime/knowledge-promotion.js +444 -0
  30. package/_modules/knowledge-engine/src/runtime/knowledge-retrieval-service.js +402 -0
  31. package/_modules/knowledge-engine/src/runtime/layout.js +44 -0
  32. package/_modules/knowledge-engine/src/runtime/markdown-knowledge-indexer.js +230 -0
  33. package/_modules/knowledge-engine/src/runtime/scanner.js +298 -0
  34. package/_modules/knowledge-engine/src/server.js +155 -0
  35. package/_modules/knowledge-engine/src/store.js +239 -0
  36. package/_modules/knowledge-engine/ui/app.css +172 -0
  37. package/_modules/knowledge-engine/ui/app.js +297 -0
  38. package/_modules/knowledge-engine/ui/index.html +95 -0
  39. package/_site/README.md +30 -30
  40. package/_site/_test/ai-profile-resolver-test.js +137 -137
  41. package/_site/_test/automation-queue-test.js +14 -14
  42. package/_site/_test/automation-ui-test.js +40 -40
  43. package/_site/_test/background-task-registry-test.js +43 -43
  44. package/_site/_test/baseline-schema-test.js +91 -91
  45. package/_site/_test/bridge-adapter-test.js +1 -1
  46. package/_site/_test/bridge-consumer-service-test.js +1 -1
  47. package/_site/_test/chat-claudecodeui-match-test.js +92 -92
  48. package/_site/_test/claude-executable-discovery-test.js +62 -62
  49. package/_site/_test/claude-workbench-test.js +170 -170
  50. package/_site/_test/client-capture-restart-e2e-test.js +4 -4
  51. package/_site/_test/codex-conversation-projection-test.js +2 -1
  52. package/_site/_test/commit-boundary-freeze-test.js +1 -1
  53. package/_site/_test/commit-conversation-binding-test.js +95 -95
  54. package/_site/_test/commit-evidence-test.js +110 -110
  55. package/_site/_test/conversation-api-test.js +109 -109
  56. package/_site/_test/conversation-store-test.js +136 -136
  57. package/_site/_test/cross-repo-isolation-e2e-test.js +2 -2
  58. package/_site/_test/data-dir-migration-test.js +57 -57
  59. package/_site/_test/desktop-browser-compat-test.js +28 -28
  60. package/_site/_test/desktop-hook-runtime-regression-test.js +204 -204
  61. package/_site/_test/explicit-commit-processor-test.js +37 -37
  62. package/_site/_test/fixtures/make-git-repos.js +12 -1
  63. package/_site/_test/folder-picker-output-test.js +19 -19
  64. package/_site/_test/full-integration-e2e-test.js +165 -165
  65. package/_site/_test/git-validation-test.js +2 -2
  66. package/_site/_test/hook-runtime-endpoint-test.js +88 -88
  67. package/_site/_test/hook-status-repair-api-test.js +152 -152
  68. package/_site/_test/hook-trigger-test.js +85 -85
  69. package/_site/_test/import-preflight-api-test.js +234 -233
  70. package/_site/_test/index-writer-concurrency-test.js +135 -135
  71. package/_site/_test/integration-adapters-test.js +171 -171
  72. package/_site/_test/integration-capture-setup-test.js +1 -1
  73. package/_site/_test/integration-surface-coverage-test.js +164 -164
  74. package/_site/_test/knowledge-language-control-test.js +170 -170
  75. package/_site/_test/knowledge-promotion-recovery-test.js +260 -260
  76. package/_site/_test/knowledge-query-test.js +54 -54
  77. package/_site/_test/knowledge-retrieval-service-test.js +77 -77
  78. package/_site/_test/knowledge-storage-startup-test.js +52 -52
  79. package/_site/_test/knowledge-store-logs-supervision-test.js +89 -89
  80. package/_site/_test/legacy-forward-compat-test.js +257 -257
  81. package/_site/_test/legacy-project-upgrade-e2e-test.js +363 -363
  82. package/_site/_test/logging-api-test.js +89 -89
  83. package/_site/_test/logging-sse-no-gap-test.js +88 -88
  84. package/_site/_test/logging-ui-test.js +106 -104
  85. package/_site/_test/markdown-delta-overlay-test.js +85 -85
  86. package/_site/_test/markdown-maintenance-api-test.js +75 -75
  87. package/_site/_test/mcp-server-test.js +149 -149
  88. package/_site/_test/module-artifact-integrity-test.js +27 -0
  89. package/_site/_test/module-boundary-test.js +30 -0
  90. package/_site/_test/module-bridge-eventbridge-test.js +7 -0
  91. package/_site/_test/module-model-configuration-test.js +43 -0
  92. package/_site/_test/module-process-lifecycle-test.js +24 -0
  93. package/_site/_test/module-stream-proxy-test.js +34 -0
  94. package/_site/_test/non-release-ci-test.js +34 -34
  95. package/_site/_test/offline-boundary-isolation-test.js +32 -32
  96. package/_site/_test/p0-data-migration-characterization-test.js +37 -37
  97. package/_site/_test/p0-e2e-gate-test.js +347 -347
  98. package/_site/_test/packaged-ui-smoke-test.js +19 -10
  99. package/_site/_test/path-consistency-test.js +151 -151
  100. package/_site/_test/pending-sweep-test.js +9 -9
  101. package/_site/_test/post-commit-automation-test.js +87 -87
  102. package/_site/_test/product-diagnostics-ui-test.js +75 -0
  103. package/_site/_test/product-import-ui-test.js +92 -0
  104. package/_site/_test/project-delete-recovery-test.js +64 -64
  105. package/_site/_test/project-goal-editor-test.js +144 -144
  106. package/_site/_test/project-layout-v2-migration-test.js +127 -127
  107. package/_site/_test/project-lifecycle-transaction-test.js +106 -106
  108. package/_site/_test/prompt-settings-test.js +115 -115
  109. package/_site/_test/protected-architecture-gate-test.js +122 -122
  110. package/_site/_test/refactor-characterization-test.js +36 -36
  111. package/_site/_test/release-version-sync-test.js +1 -1
  112. package/_site/_test/requirement-recorder-test.js +173 -173
  113. package/_site/_test/run-all-tests.js +159 -156
  114. package/_site/_test/server-runtime-migration-safety-test.js +27 -27
  115. package/_site/_test/shared-contracts-test.js +58 -58
  116. package/_site/_test/startup-analysis-disabled-test.js +10 -10
  117. package/_site/_test/storage-foundation-test.js +78 -78
  118. package/_site/_test/structured-logger-test.js +101 -101
  119. package/_site/_test/tracking-start-test.js +108 -108
  120. package/_site/_test/v4122-upgrade-data-contract-test.js +56 -56
  121. package/_site/_test/workbench-permission-test.js +117 -117
  122. package/_site/_test/workspace-ui-contract-test.js +28 -43
  123. package/_site/lib/ai-profile-resolver.js +78 -78
  124. package/_site/lib/ai-workspace.js +101 -101
  125. package/_site/lib/automation-config.js +140 -140
  126. package/_site/lib/bridge-adapter.js +5 -1
  127. package/_site/lib/bridge-consumer-service.js +402 -402
  128. package/_site/lib/claude-cli-runner.js +1510 -1510
  129. package/_site/lib/commit-conversation-binder.js +234 -234
  130. package/_site/lib/commit-processing-ledger.js +27 -27
  131. package/_site/lib/commit-prompt.js +202 -202
  132. package/_site/lib/commit-reconciler.js +406 -406
  133. package/_site/lib/contracts.js +284 -279
  134. package/_site/lib/conversation-query-service.js +144 -144
  135. package/_site/lib/conversation-store.js +385 -385
  136. package/_site/lib/data-dir.js +74 -74
  137. package/_site/lib/data-state-classifier.js +40 -40
  138. package/_site/lib/engine-cutover.js +97 -0
  139. package/_site/lib/evidence-bundle.js +228 -228
  140. package/_site/lib/folder-picker-output.js +21 -21
  141. package/_site/lib/git-runner.js +62 -62
  142. package/_site/lib/github-team-store.js +1172 -1172
  143. package/_site/lib/hook-manager.js +203 -202
  144. package/_site/lib/index-service.js +173 -173
  145. package/_site/lib/integration-installer.js +907 -907
  146. package/_site/lib/kb-framework.js +176 -176
  147. package/_site/lib/kb-validator.js +124 -124
  148. package/_site/lib/knowledge-promotion.js +435 -435
  149. package/_site/lib/knowledge-retrieval-service.js +401 -401
  150. package/_site/lib/knowledge-tool-runtime.js +359 -359
  151. package/_site/lib/legacy-data-manifest.js +26 -26
  152. package/_site/lib/llm-client.js +161 -161
  153. package/_site/lib/markdown-knowledge-indexer.js +230 -230
  154. package/_site/lib/migration-service.js +442 -442
  155. package/_site/lib/module-bridge.js +198 -33
  156. package/_site/lib/post-commit-automation.js +87 -87
  157. package/_site/lib/project-lifecycle-service.js +519 -497
  158. package/_site/lib/project-store.js +268 -262
  159. package/_site/lib/requirement-recorder.js +278 -278
  160. package/_site/lib/runtime-endpoint.js +155 -155
  161. package/_site/lib/scanner.js +298 -298
  162. package/_site/lib/server-app.js +1590 -1520
  163. package/_site/lib/settings-store.js +133 -113
  164. package/_site/lib/storage-layout.js +162 -162
  165. package/_site/lib/structured-logger.js +574 -574
  166. package/_site/scripts/folder-picker.ps1 +156 -156
  167. package/_site/scripts/hook-trigger.js +157 -156
  168. package/_site/scripts/install-module-candidates.js +69 -0
  169. package/_site/scripts/pack-module-candidates.js +72 -0
  170. package/_site/scripts/sync-release-version.js +2 -2
  171. package/_site/scripts/vendor-modules.js +47 -0
  172. package/_site/scripts/verify-module-runtime.js +45 -0
  173. package/_site/scripts/verify-product-runtime.js +34 -0
  174. package/bin/project-knowledge-mcp.js +194 -194
  175. package/docs/README.zh-CN.md +34 -25
  176. package/docs/project-registry-schema.md +22 -22
  177. package/docs/testing-strategy.md +33 -33
  178. package/module-runtime-manifest.json +220 -0
  179. package/package.json +13 -10
  180. package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
  181. package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
  182. package/plugins/project-knowledge/.mcp.json +1 -1
  183. package/plugins/project-knowledge/opencode/project-knowledge.md +3 -3
  184. package/plugins/project-knowledge/skills/project-knowledge/SKILL.md +28 -28
  185. package/templates/project-readme.md +34 -34
  186. package/ui/favicon.svg +38 -38
  187. package/ui/index.html +51 -149
  188. package/ui/product.css +9 -0
  189. package/ui/product.js +386 -0
  190. package/vendor-manifest.json +46 -0
  191. package/_site/_test/import-ui-flow-test.js +0 -171
  192. package/_site/_test/project-control-panel-task14-test.js +0 -63
  193. package/_site/_test/task15-20-ui-flow-test.js +0 -148
  194. package/_site/_test/ui-i18n-toggle-test.js +0 -114
  195. package/_site/_test/ui-smoke-test.js +0 -73
  196. package/ui/app.css +0 -58
  197. package/ui/app.js +0 -607
  198. package/ui/i18n.js +0 -146
@@ -1,262 +1,268 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const crypto = require('crypto');
4
- const {
5
- SCHEMAS,
6
- DomainError,
7
- assertMutableProjectPatch,
8
- validateProjectId,
9
- } = require('./contracts');
10
- const AtomicFile = require('./atomic-file');
11
- const { StorageLayout } = require('./storage-layout');
12
-
13
- const processQueues = new Map();
14
-
15
- function defaultProjectConfig(projectId, input = {}) {
16
- validateProjectId(projectId);
17
- const createdAt = String(input.createdAt || new Date().toISOString());
18
- return {
19
- schema: SCHEMAS.projectConfig,
20
- schemaVersion: 2,
21
- projectId,
22
- displayName: String(input.displayName || projectId),
23
- storageName: String(input.storageName || projectId),
24
- repoPath: path.resolve(String(input.repoPath || '.')),
25
- knowledgePath: path.resolve(String(input.knowledgePath || '.')),
26
- enabled: input.enabled !== false,
27
- createdAt,
28
- teamBinding: input.teamBinding || null,
29
- aiProfileId: input.aiProfileId || null,
30
- knowledgeLanguage: input.knowledgeLanguage || 'zh-CN',
31
- relatedProjectIds: Array.isArray(input.relatedProjectIds) ? [...new Set(input.relatedProjectIds)] : [],
32
- repoIdentity: input.repoIdentity || null,
33
- legacyExtensions: input.legacyExtensions || {},
34
- };
35
- }
36
-
37
- function defaultProjectState(input = {}) {
38
- return {
39
- schema: SCHEMAS.projectState,
40
- schemaVersion: 2,
41
- revision: Number.isInteger(input.revision) ? input.revision : 0,
42
- trackingStartCommit: input.trackingStartCommit || null,
43
- lastAnalyzedCommit: input.lastAnalyzedCommit || null,
44
- conversationBaselineCursor: Number.isInteger(input.conversationBaselineCursor) && input.conversationBaselineCursor >= 0
45
- ? input.conversationBaselineCursor
46
- : null,
47
- conversation: {
48
- lastConsumedCursor: Number.isInteger(input.conversation && input.conversation.lastConsumedCursor)
49
- ? input.conversation.lastConsumedCursor
50
- : null,
51
- captureStatus: input.conversation && input.conversation.captureStatus || 'unavailable',
52
- lastError: input.conversation && input.conversation.lastError || null,
53
- },
54
- trackingMode: input.trackingMode === 'empty-repo' ? 'empty-repo' : 'normal',
55
- analysis: {
56
- status: input.analysis && input.analysis.status || 'idle',
57
- activeClaim: input.analysis && input.analysis.activeClaim || null,
58
- lastError: input.analysis && input.analysis.lastError || null,
59
- rescanRequested: input.analysis && input.analysis.rescanRequested === true,
60
- },
61
- index: {
62
- dirty: input.index && input.index.dirty === true,
63
- generation: Number(input.index && input.index.generation || 0),
64
- sinceCommit: input.index && input.index.sinceCommit || null,
65
- lastError: input.index && input.index.lastError || null,
66
- },
67
- hook: {
68
- managedVersion: Number(input.hook && input.hook.managedVersion || 0),
69
- migrationVersion: Number(input.hook && input.hook.migrationVersion || 0),
70
- lastVerifiedAt: input.hook && input.hook.lastVerifiedAt || '',
71
- },
72
- updatedAt: input.updatedAt || new Date().toISOString(),
73
- };
74
- }
75
-
76
- function validateProjectConfig(config, expectedProjectId) {
77
- if (!config || typeof config !== 'object' || Array.isArray(config)) throw new DomainError('DATA_CORRUPT', 'Project config is invalid.', { status: 500 });
78
- if (config.schema !== SCHEMAS.projectConfig || config.schemaVersion !== 2) throw new DomainError('SCHEMA_UNSUPPORTED', 'Unsupported project config schema.', { status: 409 });
79
- validateProjectId(config.projectId);
80
- if (expectedProjectId && config.projectId !== expectedProjectId) throw new DomainError('DATA_CORRUPT', 'Project config identity mismatch.', { status: 500 });
81
- if (!config.storageName || !config.repoPath || !config.knowledgePath || !config.createdAt) throw new DomainError('DATA_CORRUPT', 'Project config is incomplete.', { status: 500 });
82
- return config;
83
- }
84
-
85
- function validateProjectState(state) {
86
- if (!state || typeof state !== 'object' || Array.isArray(state)) throw new DomainError('DATA_CORRUPT', 'Project state is invalid.', { status: 500 });
87
- if (state.schema !== SCHEMAS.projectState || state.schemaVersion !== 2) throw new DomainError('SCHEMA_UNSUPPORTED', 'Unsupported project state schema.', { status: 409 });
88
- if (!Number.isInteger(state.revision) || !state.analysis || !state.index || !state.hook) throw new DomainError('DATA_CORRUPT', 'Project state is incomplete.', { status: 500 });
89
- if (!['normal', 'empty-repo'].includes(state.trackingMode)) throw new DomainError('DATA_CORRUPT', 'Project tracking mode is invalid.', { status: 500 });
90
- if (!Object.prototype.hasOwnProperty.call(state, 'conversationBaselineCursor')) state.conversationBaselineCursor = null;
91
- if (!state.conversation || typeof state.conversation !== 'object') {
92
- state.conversation = { lastConsumedCursor: null, captureStatus: 'unavailable', lastError: null };
93
- }
94
- return state;
95
- }
96
-
97
- class ProjectStore {
98
- constructor(options = {}) {
99
- this.layout = options.layout || new StorageLayout(options);
100
- this.atomic = options.atomic || AtomicFile;
101
- }
102
-
103
- async withProjectLock(projectId, fn, options = {}) {
104
- validateProjectId(projectId);
105
- const previous = processQueues.get(projectId) || Promise.resolve();
106
- let releaseQueue;
107
- const current = new Promise(resolve => { releaseQueue = resolve; });
108
- const queued = previous.then(() => current);
109
- processQueues.set(projectId, queued);
110
- await previous;
111
- try {
112
- return await this.atomic.withFileLock(this.layout.getProjectLockPath(projectId), fn, options);
113
- } finally {
114
- releaseQueue();
115
- if (processQueues.get(projectId) === queued) processQueues.delete(projectId);
116
- }
117
- }
118
-
119
- readConfig(projectId) {
120
- validateProjectId(projectId);
121
- return this.atomic.readJsonStrict(this.layout.getProjectConfigPath(projectId), {
122
- category: 'project-config',
123
- validate: config => validateProjectConfig(config, projectId),
124
- });
125
- }
126
-
127
- readState(projectId) {
128
- validateProjectId(projectId);
129
- return this.atomic.readJsonStrict(this.layout.getProjectStatePath(projectId), {
130
- category: 'project-state',
131
- validate: validateProjectState,
132
- });
133
- }
134
-
135
- async create(projectId, configInput, stateInput = {}) {
136
- validateProjectId(projectId);
137
- return this.withProjectLock(projectId, async () => {
138
- const dir = this.layout.getProjectMetadataDir(projectId);
139
- const configPath = this.layout.getProjectConfigPath(projectId);
140
- const statePath = this.layout.getProjectStatePath(projectId);
141
- if (fs.existsSync(configPath) || fs.existsSync(statePath)) throw new DomainError('INVALID_ARGUMENT', 'Project metadata already exists.', { status: 409 });
142
- fs.mkdirSync(dir, { recursive: true });
143
- const config = validateProjectConfig(defaultProjectConfig(projectId, configInput), projectId);
144
- const state = validateProjectState(defaultProjectState(stateInput));
145
- try {
146
- this.atomic.writeJsonAtomic(configPath, config);
147
- this.atomic.writeJsonAtomic(statePath, state);
148
- } catch (error) {
149
- try { if (fs.existsSync(configPath)) fs.unlinkSync(configPath); } catch {
150
- // Preserve the original create error; the lifecycle journal records rollback residue.
151
- }
152
- try { if (fs.existsSync(statePath)) fs.unlinkSync(statePath); } catch {
153
- // Preserve the original create error; the lifecycle journal records rollback residue.
154
- }
155
- throw error;
156
- }
157
- return { config, state };
158
- });
159
- }
160
-
161
- async updateConfig(projectId, patch, options = {}) {
162
- validateProjectId(projectId);
163
- assertMutableProjectPatch(patch, {
164
- allowKnowledgePath: options.allowKnowledgePath === true,
165
- allowRepoPath: options.allowRepoPath === true,
166
- });
167
- return this.withProjectLock(projectId, async () => {
168
- const current = this.readConfig(projectId);
169
- const next = { ...current, ...patch, projectId: current.projectId, storageName: current.storageName, createdAt: current.createdAt };
170
- validateProjectConfig(next, projectId);
171
- this.atomic.writeJsonAtomic(this.layout.getProjectConfigPath(projectId), next);
172
- return next;
173
- });
174
- }
175
-
176
- /**
177
- * Deliberate one-way upgrade: write a canonical repo-identity/v1 onto a
178
- * project that was imported before v4.2.3 (repoIdentity null or a legacy
179
- * { commonDir }-only shape). Only the Bridge consumer migration calls
180
- * this; it is intentionally not reachable through the generic config
181
- * update path. Rewrites are refused once a v1 identity exists.
182
- */
183
- async migrateRepoIdentity(projectId, repoIdentity) {
184
- validateProjectId(projectId);
185
- if (!repoIdentity || typeof repoIdentity !== 'object'
186
- || repoIdentity.schema !== 'repo-identity/v1'
187
- || typeof repoIdentity.workspaceId !== 'string'
188
- || typeof repoIdentity.workspaceRoot !== 'string' || !repoIdentity.workspaceRoot) {
189
- throw new DomainError('INVALID_ARGUMENT', 'migrateRepoIdentity requires a canonical repo-identity/v1 object.');
190
- }
191
- return this.withProjectLock(projectId, async () => {
192
- const current = this.readConfig(projectId);
193
- if (current.repoIdentity && typeof current.repoIdentity.workspaceId === 'string') {
194
- return current;
195
- }
196
- const next = { ...current, repoIdentity };
197
- validateProjectConfig(next, projectId);
198
- this.atomic.writeJsonAtomic(this.layout.getProjectConfigPath(projectId), next);
199
- return next;
200
- });
201
- }
202
-
203
- async updateState(projectId, updater, options = {}) {
204
- validateProjectId(projectId);
205
- if (typeof updater !== 'function') throw new DomainError('INVALID_ARGUMENT', 'State updater must be a function.');
206
- return this.withProjectLock(projectId, async () => {
207
- const current = this.readState(projectId);
208
- if (Number.isInteger(options.expectedRevision) && current.revision !== options.expectedRevision) {
209
- throw new DomainError('PROJECT_BUSY', 'Project state revision changed.', { status: 409, retryable: true });
210
- }
211
- const draft = JSON.parse(JSON.stringify(current));
212
- const updated = await updater(draft);
213
- const next = updated && typeof updated === 'object' ? updated : draft;
214
- next.schema = SCHEMAS.projectState;
215
- next.schemaVersion = 2;
216
- next.revision = current.revision + 1;
217
- next.updatedAt = new Date().toISOString();
218
- validateProjectState(next);
219
- this.atomic.writeJsonAtomic(this.layout.getProjectStatePath(projectId), next);
220
- return next;
221
- });
222
- }
223
-
224
- async appendRequirement(projectId, record) {
225
- validateProjectId(projectId);
226
- if (!record || typeof record !== 'object' || record.schema !== SCHEMAS.requirement || record.projectId !== projectId || !record.id) {
227
- throw new DomainError('INVALID_ARGUMENT', 'Requirement record is invalid.');
228
- }
229
- const filePath = this.layout.getProjectRequirementsPath(projectId);
230
- await this.atomic.appendJsonlLocked(filePath, record, { lockPath: `${filePath}.append.lock` });
231
- return record;
232
- }
233
-
234
- readRequirements(projectId) {
235
- validateProjectId(projectId);
236
- const filePath = this.layout.getProjectRequirementsPath(projectId);
237
- if (!fs.existsSync(filePath)) return [];
238
- const records = [];
239
- const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
240
- for (let index = 0; index < lines.length; index += 1) {
241
- if (!lines[index].trim()) continue;
242
- try {
243
- const record = JSON.parse(lines[index]);
244
- if (record.schema !== SCHEMAS.requirement || record.projectId !== projectId) throw new Error('schema or project mismatch');
245
- records.push(record);
246
- } catch (error) {
247
- throw new DomainError('DATA_CORRUPT', 'Requirements JSONL is corrupt.', { status: 500, cause: error, details: { line: index + 1 } });
248
- }
249
- }
250
- return records;
251
- }
252
-
253
- generateRequirementId() { return `req-${crypto.randomUUID()}`; }
254
- }
255
-
256
- module.exports = {
257
- ProjectStore,
258
- defaultProjectConfig,
259
- defaultProjectState,
260
- validateProjectConfig,
261
- validateProjectState,
262
- };
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const crypto = require('crypto');
4
+ const {
5
+ SCHEMAS,
6
+ DomainError,
7
+ assertMutableProjectPatch,
8
+ validateProjectId,
9
+ } = require('./contracts');
10
+ const AtomicFile = require('./atomic-file');
11
+ const { StorageLayout } = require('./storage-layout');
12
+
13
+ const processQueues = new Map();
14
+
15
+ function defaultProjectConfig(projectId, input = {}) {
16
+ validateProjectId(projectId);
17
+ const createdAt = String(input.createdAt || new Date().toISOString());
18
+ return {
19
+ schema: SCHEMAS.projectConfig,
20
+ schemaVersion: 2,
21
+ projectId,
22
+ displayName: String(input.displayName || projectId),
23
+ storageName: String(input.storageName || projectId),
24
+ repoPath: path.resolve(String(input.repoPath || '.')),
25
+ knowledgePath: path.resolve(String(input.knowledgePath || '.')),
26
+ enabled: input.enabled !== false,
27
+ createdAt,
28
+ teamBinding: input.teamBinding || null,
29
+ aiProfileId: input.aiProfileId || null,
30
+ knowledgeLanguage: input.knowledgeLanguage || 'zh-CN',
31
+ brainId: input.brainId || 'brain-default',
32
+ domainId: input.domainId || null,
33
+ relatedProjectIds: Array.isArray(input.relatedProjectIds) ? [...new Set(input.relatedProjectIds)] : [],
34
+ repoIdentity: input.repoIdentity || null,
35
+ legacyExtensions: input.legacyExtensions || {},
36
+ };
37
+ }
38
+
39
+ function defaultProjectState(input = {}) {
40
+ return {
41
+ schema: SCHEMAS.projectState,
42
+ schemaVersion: 2,
43
+ revision: Number.isInteger(input.revision) ? input.revision : 0,
44
+ trackingStartCommit: input.trackingStartCommit || null,
45
+ lastAnalyzedCommit: input.lastAnalyzedCommit || null,
46
+ conversationBaselineCursor: Number.isInteger(input.conversationBaselineCursor) && input.conversationBaselineCursor >= 0
47
+ ? input.conversationBaselineCursor
48
+ : null,
49
+ conversation: {
50
+ lastConsumedCursor: Number.isInteger(input.conversation && input.conversation.lastConsumedCursor)
51
+ ? input.conversation.lastConsumedCursor
52
+ : null,
53
+ captureStatus: input.conversation && input.conversation.captureStatus || 'unavailable',
54
+ lastError: input.conversation && input.conversation.lastError || null,
55
+ },
56
+ trackingMode: input.trackingMode === 'empty-repo' ? 'empty-repo' : 'normal',
57
+ analysis: {
58
+ status: input.analysis && input.analysis.status || 'idle',
59
+ activeClaim: input.analysis && input.analysis.activeClaim || null,
60
+ lastError: input.analysis && input.analysis.lastError || null,
61
+ rescanRequested: input.analysis && input.analysis.rescanRequested === true,
62
+ },
63
+ index: {
64
+ dirty: input.index && input.index.dirty === true,
65
+ generation: Number(input.index && input.index.generation || 0),
66
+ sinceCommit: input.index && input.index.sinceCommit || null,
67
+ lastError: input.index && input.index.lastError || null,
68
+ },
69
+ hook: {
70
+ managedVersion: Number(input.hook && input.hook.managedVersion || 0),
71
+ migrationVersion: Number(input.hook && input.hook.migrationVersion || 0),
72
+ lastVerifiedAt: input.hook && input.hook.lastVerifiedAt || '',
73
+ },
74
+ updatedAt: input.updatedAt || new Date().toISOString(),
75
+ };
76
+ }
77
+
78
+ function validateProjectConfig(config, expectedProjectId) {
79
+ if (!config || typeof config !== 'object' || Array.isArray(config)) throw new DomainError('DATA_CORRUPT', 'Project config is invalid.', { status: 500 });
80
+ if (config.schema !== SCHEMAS.projectConfig || config.schemaVersion !== 2) throw new DomainError('SCHEMA_UNSUPPORTED', 'Unsupported project config schema.', { status: 409 });
81
+ validateProjectId(config.projectId);
82
+ if (expectedProjectId && config.projectId !== expectedProjectId) throw new DomainError('DATA_CORRUPT', 'Project config identity mismatch.', { status: 500 });
83
+ if (!config.storageName || !config.repoPath || !config.knowledgePath || !config.createdAt) throw new DomainError('DATA_CORRUPT', 'Project config is incomplete.', { status: 500 });
84
+ config.brainId = config.brainId || 'brain-default';
85
+ config.domainId = config.domainId || null;
86
+ validateProjectId(config.brainId);
87
+ if (config.domainId) validateProjectId(config.domainId);
88
+ return config;
89
+ }
90
+
91
+ function validateProjectState(state) {
92
+ if (!state || typeof state !== 'object' || Array.isArray(state)) throw new DomainError('DATA_CORRUPT', 'Project state is invalid.', { status: 500 });
93
+ if (state.schema !== SCHEMAS.projectState || state.schemaVersion !== 2) throw new DomainError('SCHEMA_UNSUPPORTED', 'Unsupported project state schema.', { status: 409 });
94
+ if (!Number.isInteger(state.revision) || !state.analysis || !state.index || !state.hook) throw new DomainError('DATA_CORRUPT', 'Project state is incomplete.', { status: 500 });
95
+ if (!['normal', 'empty-repo'].includes(state.trackingMode)) throw new DomainError('DATA_CORRUPT', 'Project tracking mode is invalid.', { status: 500 });
96
+ if (!Object.prototype.hasOwnProperty.call(state, 'conversationBaselineCursor')) state.conversationBaselineCursor = null;
97
+ if (!state.conversation || typeof state.conversation !== 'object') {
98
+ state.conversation = { lastConsumedCursor: null, captureStatus: 'unavailable', lastError: null };
99
+ }
100
+ return state;
101
+ }
102
+
103
+ class ProjectStore {
104
+ constructor(options = {}) {
105
+ this.layout = options.layout || new StorageLayout(options);
106
+ this.atomic = options.atomic || AtomicFile;
107
+ }
108
+
109
+ async withProjectLock(projectId, fn, options = {}) {
110
+ validateProjectId(projectId);
111
+ const previous = processQueues.get(projectId) || Promise.resolve();
112
+ let releaseQueue;
113
+ const current = new Promise(resolve => { releaseQueue = resolve; });
114
+ const queued = previous.then(() => current);
115
+ processQueues.set(projectId, queued);
116
+ await previous;
117
+ try {
118
+ return await this.atomic.withFileLock(this.layout.getProjectLockPath(projectId), fn, options);
119
+ } finally {
120
+ releaseQueue();
121
+ if (processQueues.get(projectId) === queued) processQueues.delete(projectId);
122
+ }
123
+ }
124
+
125
+ readConfig(projectId) {
126
+ validateProjectId(projectId);
127
+ return this.atomic.readJsonStrict(this.layout.getProjectConfigPath(projectId), {
128
+ category: 'project-config',
129
+ validate: config => validateProjectConfig(config, projectId),
130
+ });
131
+ }
132
+
133
+ readState(projectId) {
134
+ validateProjectId(projectId);
135
+ return this.atomic.readJsonStrict(this.layout.getProjectStatePath(projectId), {
136
+ category: 'project-state',
137
+ validate: validateProjectState,
138
+ });
139
+ }
140
+
141
+ async create(projectId, configInput, stateInput = {}) {
142
+ validateProjectId(projectId);
143
+ return this.withProjectLock(projectId, async () => {
144
+ const dir = this.layout.getProjectMetadataDir(projectId);
145
+ const configPath = this.layout.getProjectConfigPath(projectId);
146
+ const statePath = this.layout.getProjectStatePath(projectId);
147
+ if (fs.existsSync(configPath) || fs.existsSync(statePath)) throw new DomainError('INVALID_ARGUMENT', 'Project metadata already exists.', { status: 409 });
148
+ fs.mkdirSync(dir, { recursive: true });
149
+ const config = validateProjectConfig(defaultProjectConfig(projectId, configInput), projectId);
150
+ const state = validateProjectState(defaultProjectState(stateInput));
151
+ try {
152
+ this.atomic.writeJsonAtomic(configPath, config);
153
+ this.atomic.writeJsonAtomic(statePath, state);
154
+ } catch (error) {
155
+ try { if (fs.existsSync(configPath)) fs.unlinkSync(configPath); } catch {
156
+ // Preserve the original create error; the lifecycle journal records rollback residue.
157
+ }
158
+ try { if (fs.existsSync(statePath)) fs.unlinkSync(statePath); } catch {
159
+ // Preserve the original create error; the lifecycle journal records rollback residue.
160
+ }
161
+ throw error;
162
+ }
163
+ return { config, state };
164
+ });
165
+ }
166
+
167
+ async updateConfig(projectId, patch, options = {}) {
168
+ validateProjectId(projectId);
169
+ assertMutableProjectPatch(patch, {
170
+ allowKnowledgePath: options.allowKnowledgePath === true,
171
+ allowRepoPath: options.allowRepoPath === true,
172
+ });
173
+ return this.withProjectLock(projectId, async () => {
174
+ const current = this.readConfig(projectId);
175
+ const next = { ...current, ...patch, projectId: current.projectId, storageName: current.storageName, createdAt: current.createdAt };
176
+ validateProjectConfig(next, projectId);
177
+ this.atomic.writeJsonAtomic(this.layout.getProjectConfigPath(projectId), next);
178
+ return next;
179
+ });
180
+ }
181
+
182
+ /**
183
+ * Deliberate one-way upgrade: write a canonical repo-identity/v1 onto a
184
+ * project that was imported before v4.2.3 (repoIdentity null or a legacy
185
+ * { commonDir }-only shape). Only the Bridge consumer migration calls
186
+ * this; it is intentionally not reachable through the generic config
187
+ * update path. Rewrites are refused once a v1 identity exists.
188
+ */
189
+ async migrateRepoIdentity(projectId, repoIdentity) {
190
+ validateProjectId(projectId);
191
+ if (!repoIdentity || typeof repoIdentity !== 'object'
192
+ || repoIdentity.schema !== 'repo-identity/v1'
193
+ || typeof repoIdentity.workspaceId !== 'string'
194
+ || typeof repoIdentity.workspaceRoot !== 'string' || !repoIdentity.workspaceRoot) {
195
+ throw new DomainError('INVALID_ARGUMENT', 'migrateRepoIdentity requires a canonical repo-identity/v1 object.');
196
+ }
197
+ return this.withProjectLock(projectId, async () => {
198
+ const current = this.readConfig(projectId);
199
+ if (current.repoIdentity && typeof current.repoIdentity.workspaceId === 'string') {
200
+ return current;
201
+ }
202
+ const next = { ...current, repoIdentity };
203
+ validateProjectConfig(next, projectId);
204
+ this.atomic.writeJsonAtomic(this.layout.getProjectConfigPath(projectId), next);
205
+ return next;
206
+ });
207
+ }
208
+
209
+ async updateState(projectId, updater, options = {}) {
210
+ validateProjectId(projectId);
211
+ if (typeof updater !== 'function') throw new DomainError('INVALID_ARGUMENT', 'State updater must be a function.');
212
+ return this.withProjectLock(projectId, async () => {
213
+ const current = this.readState(projectId);
214
+ if (Number.isInteger(options.expectedRevision) && current.revision !== options.expectedRevision) {
215
+ throw new DomainError('PROJECT_BUSY', 'Project state revision changed.', { status: 409, retryable: true });
216
+ }
217
+ const draft = JSON.parse(JSON.stringify(current));
218
+ const updated = await updater(draft);
219
+ const next = updated && typeof updated === 'object' ? updated : draft;
220
+ next.schema = SCHEMAS.projectState;
221
+ next.schemaVersion = 2;
222
+ next.revision = current.revision + 1;
223
+ next.updatedAt = new Date().toISOString();
224
+ validateProjectState(next);
225
+ this.atomic.writeJsonAtomic(this.layout.getProjectStatePath(projectId), next);
226
+ return next;
227
+ });
228
+ }
229
+
230
+ async appendRequirement(projectId, record) {
231
+ validateProjectId(projectId);
232
+ if (!record || typeof record !== 'object' || record.schema !== SCHEMAS.requirement || record.projectId !== projectId || !record.id) {
233
+ throw new DomainError('INVALID_ARGUMENT', 'Requirement record is invalid.');
234
+ }
235
+ const filePath = this.layout.getProjectRequirementsPath(projectId);
236
+ await this.atomic.appendJsonlLocked(filePath, record, { lockPath: `${filePath}.append.lock` });
237
+ return record;
238
+ }
239
+
240
+ readRequirements(projectId) {
241
+ validateProjectId(projectId);
242
+ const filePath = this.layout.getProjectRequirementsPath(projectId);
243
+ if (!fs.existsSync(filePath)) return [];
244
+ const records = [];
245
+ const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
246
+ for (let index = 0; index < lines.length; index += 1) {
247
+ if (!lines[index].trim()) continue;
248
+ try {
249
+ const record = JSON.parse(lines[index]);
250
+ if (record.schema !== SCHEMAS.requirement || record.projectId !== projectId) throw new Error('schema or project mismatch');
251
+ records.push(record);
252
+ } catch (error) {
253
+ throw new DomainError('DATA_CORRUPT', 'Requirements JSONL is corrupt.', { status: 500, cause: error, details: { line: index + 1 } });
254
+ }
255
+ }
256
+ return records;
257
+ }
258
+
259
+ generateRequirementId() { return `req-${crypto.randomUUID()}`; }
260
+ }
261
+
262
+ module.exports = {
263
+ ProjectStore,
264
+ defaultProjectConfig,
265
+ defaultProjectState,
266
+ validateProjectConfig,
267
+ validateProjectState,
268
+ };