@straiffi/archon 1.4.12 → 1.6.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.
- package/dist/client/assets/{GitHubActionsDialog-DcW7fUgz.js → GitHubActionsDialog-oMY61JXF.js} +1 -1
- package/dist/client/assets/{TestsDialog-xNEttQOx.js → TestsDialog-elElGi9L.js} +3 -4
- package/dist/client/assets/{architectureDiagram-3BPJPVTR-DHADo63Y.js → architectureDiagram-3BPJPVTR-BNcdEMha.js} +1 -1
- package/dist/client/assets/{blockDiagram-GPEHLZMM-D_LqGLuD.js → blockDiagram-GPEHLZMM-xHTKb0-z.js} +1 -1
- package/dist/client/assets/{c4Diagram-AAUBKEIU-mIqv7fCU.js → c4Diagram-AAUBKEIU-kMngbYRg.js} +1 -1
- package/dist/client/assets/channel-DAW1OrrC.js +1 -0
- package/dist/client/assets/{chunk-2J33WTMH-DP-b68f-.js → chunk-2J33WTMH-BVZnJcYa.js} +1 -1
- package/dist/client/assets/{chunk-3OPIFGDE-D4GWQbqc.js → chunk-3OPIFGDE-Dq_H_MkG.js} +1 -1
- package/dist/client/assets/{chunk-5ZQYHXKU-HftAkT9j.js → chunk-5ZQYHXKU-_TwHLQwE.js} +1 -1
- package/dist/client/assets/{chunk-727SXJPM-CvB0JQZP.js → chunk-727SXJPM-CKkS_93w.js} +1 -1
- package/dist/client/assets/{chunk-AQP2D5EJ-DSB28EAb.js → chunk-AQP2D5EJ-BHSSO0SM.js} +1 -1
- package/dist/client/assets/{chunk-CSCIHK7Q-DuSgBocb.js → chunk-CSCIHK7Q-CgzsiMAL.js} +1 -1
- package/dist/client/assets/{chunk-KSCS5N6A-cm_ppJ7v.js → chunk-KSCS5N6A-DM-rnicK.js} +1 -1
- package/dist/client/assets/{chunk-L5ZTLDWV-BzDHIV9v.js → chunk-L5ZTLDWV-CnMgoXZs.js} +1 -1
- package/dist/client/assets/{chunk-LZXEDZCA-BruMwvko.js → chunk-LZXEDZCA-CCr2z1iU.js} +2 -2
- package/dist/client/assets/{chunk-ND2GUHAM-DrnvodkL.js → chunk-ND2GUHAM-DtA6gjMn.js} +1 -1
- package/dist/client/assets/{chunk-NZK2D7GU-QVsa0LuF.js → chunk-NZK2D7GU-_8fmSSaf.js} +1 -1
- package/dist/client/assets/{chunk-O5CBEL6O-DJFhq2gr.js → chunk-O5CBEL6O-B84elqwU.js} +1 -1
- package/dist/client/assets/{chunk-WU5MYG2G-ieLjE2uQ.js → chunk-WU5MYG2G-DBvmvPPt.js} +1 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-BmFh-n6n.js +1 -0
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-tA1aAbh4.js +1 -0
- package/dist/client/assets/{dagre-BM42HDAG-CFRh82aT.js → dagre-BM42HDAG-C-QssoNU.js} +1 -1
- package/dist/client/assets/{diagram-2AECGRRQ-CPk1WJvx.js → diagram-2AECGRRQ-8RpG5DGb.js} +1 -1
- package/dist/client/assets/{diagram-5GNKFQAL-DHMJ735r.js → diagram-5GNKFQAL-Dk2raJWq.js} +1 -1
- package/dist/client/assets/{diagram-KO2AKTUF-Cq9d2BWb.js → diagram-KO2AKTUF-7iZuIIef.js} +1 -1
- package/dist/client/assets/{diagram-LMA3HP47-CgWJTR_b.js → diagram-LMA3HP47-JLaaaEE6.js} +1 -1
- package/dist/client/assets/{diagram-OG6HWLK6-HoFYGw81.js → diagram-OG6HWLK6-jeL3-Rd3.js} +1 -1
- package/dist/client/assets/{erDiagram-TEJ5UH35-B7yuPZck.js → erDiagram-TEJ5UH35-B6uFrMrQ.js} +1 -1
- package/dist/client/assets/{flowDiagram-I6XJVG4X-DaXXRHQj.js → flowDiagram-I6XJVG4X-BFWnhmx2.js} +1 -1
- package/dist/client/assets/{ganttDiagram-6RSMTGT7-pKsFmtHB.js → ganttDiagram-6RSMTGT7-nxON-689.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-PVQCEYII-Dwx5kc1M.js → gitGraphDiagram-PVQCEYII-DEAsC0GZ.js} +1 -1
- package/dist/client/assets/index-BzDXl4AF.js +265 -0
- package/dist/client/assets/index-DSap5tSX.css +2 -0
- package/dist/client/assets/{infoDiagram-5YYISTIA-6Taupp-l.js → infoDiagram-5YYISTIA-CAZHR2I8.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-BBKjiFtW.js → ishikawaDiagram-YF4QCWOH-DEe4SNzl.js} +1 -1
- package/dist/client/assets/{journeyDiagram-JHISSGLW-BQQ1EKJ3.js → journeyDiagram-JHISSGLW-CG2kTAj9.js} +1 -1
- package/dist/client/assets/{kanban-definition-UN3LZRKU-BvsroFzw.js → kanban-definition-UN3LZRKU-DUWutAjA.js} +1 -1
- package/dist/client/assets/{line-C6BHsie7.js → line-B1SFN8mz.js} +1 -1
- package/dist/client/assets/{mermaid-parser.core-Cb4G8u3H.js → mermaid-parser.core-C-dyz_nb.js} +1 -1
- package/dist/client/assets/{mermaid.core-NCO8Z4wR.js → mermaid.core-nm4BMZuF.js} +3 -3
- package/dist/client/assets/{mindmap-definition-RKZ34NQL-DYzSjAq3.js → mindmap-definition-RKZ34NQL-DZEd-RCz.js} +1 -1
- package/dist/client/assets/{pieDiagram-4H26LBE5-CWMyFFuU.js → pieDiagram-4H26LBE5-WXxrzUwX.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-W4KKPZXB-D1ifIucc.js → quadrantDiagram-W4KKPZXB-BClH2kQx.js} +1 -1
- package/dist/client/assets/{requirementDiagram-4Y6WPE33-DW68fC21.js → requirementDiagram-4Y6WPE33-ByolUxfP.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-5OEKKPKP-DnnV47BL.js → sankeyDiagram-5OEKKPKP-B2vcb3U1.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-3UESZ5HK-Dw-9ZBU_.js → sequenceDiagram-3UESZ5HK-J8Nt0GXG.js} +1 -1
- package/dist/client/assets/{stateDiagram-AJRCARHV-C7dkf8nk.js → stateDiagram-AJRCARHV-CrgZ4Uwa.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CUEBQOsD.js +1 -0
- package/dist/client/assets/{timeline-definition-PNZ67QCA-BmVfK_16.js → timeline-definition-PNZ67QCA-CXebl6LI.js} +1 -1
- package/dist/client/assets/{vennDiagram-CIIHVFJN-B9j3QbmD.js → vennDiagram-CIIHVFJN-DLBqI39h.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-YWT4CUSO-C520auMy.js → wardleyDiagram-YWT4CUSO-ku4fViV9.js} +1 -1
- package/dist/client/assets/{xychartDiagram-2RQKCTM6-CPqUJuqs.js → xychartDiagram-2RQKCTM6-5Jh-bBeb.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/db.js +291 -3
- package/dist/server/db.js.map +1 -1
- package/dist/server/index.js +339 -97
- package/dist/server/index.js.map +1 -1
- package/dist/server/lib/agent.js +853 -161
- package/dist/server/lib/agent.js.map +1 -1
- package/dist/server/lib/agentRuns.js +22 -0
- package/dist/server/lib/agentRuns.js.map +1 -1
- package/dist/server/lib/chatMessages.js +24 -8
- package/dist/server/lib/chatMessages.js.map +1 -1
- package/dist/server/lib/genericToolCall.js +151 -0
- package/dist/server/lib/genericToolCall.js.map +1 -0
- package/dist/server/lib/git.js +18 -0
- package/dist/server/lib/git.js.map +1 -1
- package/dist/server/lib/mobileAccess.js +45 -2
- package/dist/server/lib/mobileAccess.js.map +1 -1
- package/dist/server/lib/models.js +65 -48
- package/dist/server/lib/models.js.map +1 -1
- package/dist/server/lib/opencodeTaskSessions.js +216 -0
- package/dist/server/lib/opencodeTaskSessions.js.map +1 -0
- package/dist/server/lib/projectMemory.js +1073 -276
- package/dist/server/lib/projectMemory.js.map +1 -1
- package/dist/server/lib/projectMemoryCursor.js +27 -0
- package/dist/server/lib/projectMemoryCursor.js.map +1 -0
- package/dist/server/lib/projectMemoryEvents.js +24 -0
- package/dist/server/lib/projectMemoryEvents.js.map +1 -0
- package/dist/server/lib/projectMemoryLimits.js +61 -0
- package/dist/server/lib/projectMemoryLimits.js.map +1 -0
- package/dist/server/lib/projectMemoryPrompt.js +299 -110
- package/dist/server/lib/projectMemoryPrompt.js.map +1 -1
- package/dist/server/lib/projectMemoryScan.js +461 -493
- package/dist/server/lib/projectMemoryScan.js.map +1 -1
- package/dist/server/lib/projectMemorySelections.js +97 -0
- package/dist/server/lib/projectMemorySelections.js.map +1 -0
- package/dist/server/lib/projectMemorySuggestions.js +19 -4
- package/dist/server/lib/projectMemorySuggestions.js.map +1 -1
- package/dist/server/lib/projectMemoryTransfer.js +635 -83
- package/dist/server/lib/projectMemoryTransfer.js.map +1 -1
- package/dist/server/lib/projectPathGlob.js +40 -0
- package/dist/server/lib/projectPathGlob.js.map +1 -0
- package/dist/server/lib/projects.js +50 -0
- package/dist/server/lib/projects.js.map +1 -1
- package/dist/server/lib/promptSkills.js +5 -0
- package/dist/server/lib/promptSkills.js.map +1 -1
- package/dist/server/lib/specMessages.js +9 -6
- package/dist/server/lib/specMessages.js.map +1 -1
- package/dist/server/lib/specReviewContext.js +2 -1
- package/dist/server/lib/specReviewContext.js.map +1 -1
- package/dist/server/lib/ticketMessages.js +22 -7
- package/dist/server/lib/ticketMessages.js.map +1 -1
- package/dist/server/lib/tickets.js +6 -0
- package/dist/server/lib/tickets.js.map +1 -1
- package/dist/server/lib/transcriptRuns.js +85 -0
- package/dist/server/lib/transcriptRuns.js.map +1 -0
- package/dist/server/workers/build.js +18 -4
- package/dist/server/workers/build.js.map +1 -1
- package/dist/server/workers/chat.js +24 -7
- package/dist/server/workers/chat.js.map +1 -1
- package/dist/server/workers/followUp.js +27 -10
- package/dist/server/workers/followUp.js.map +1 -1
- package/dist/server/workers/plan.js +405 -69
- package/dist/server/workers/plan.js.map +1 -1
- package/dist/server/workers/planFollowUp.js +30 -7
- package/dist/server/workers/planFollowUp.js.map +1 -1
- package/dist/server/workers/review.js +76 -20
- package/dist/server/workers/review.js.map +1 -1
- package/dist/server/workers/specReview.js +10 -0
- package/dist/server/workers/specReview.js.map +1 -1
- package/dist/server/workers/specSession.js +37 -3
- package/dist/server/workers/specSession.js.map +1 -1
- package/package.json +1 -1
- package/dist/client/assets/channel-D7PWuiXU.js +0 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-BNIT931s.js +0 -1
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-TRtc0GGS.js +0 -1
- package/dist/client/assets/index-5Eun897r.js +0 -260
- package/dist/client/assets/index-CWWEw76C.css +0 -2
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-BFmoroJA.js +0 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { randomUUID } from 'crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'crypto';
|
|
2
2
|
import db from '../db.js';
|
|
3
|
+
import { containsReservedProjectMemoryControlMarker, PROJECT_MEMORY_LIMITS } from './projectMemoryLimits.js';
|
|
4
|
+
import { encodeProjectMemoryCursor } from './projectMemoryCursor.js';
|
|
3
5
|
const PROJECT_MEMORY_STAGES = ['plan', 'build', 'review', 'follow_up', 'plan_follow_up', 'chat'];
|
|
4
6
|
const PROJECT_CONVENTION_PRIORITIES = ['critical', 'normal', 'low'];
|
|
5
7
|
const PROJECT_CONVENTION_INJECTION_MODES = ['always', 'relevant'];
|
|
@@ -9,6 +11,11 @@ const PROJECT_MEMORY_SOURCES = ['manual', 'agent'];
|
|
|
9
11
|
const PROJECT_MEMORY_SUGGESTION_KINDS = ['decision', 'convention'];
|
|
10
12
|
const PROJECT_MEMORY_SUGGESTION_STATUSES = ['pending', 'accepted', 'dismissed', 'applied_to_existing'];
|
|
11
13
|
const PROJECT_MEMORY_SUGGESTION_DUPLICATE_KINDS = ['none', 'near_duplicate'];
|
|
14
|
+
const PROJECT_MEMORY_SUGGESTION_DISMISSAL_REASONS = ['incorrect', 'not_durable', 'already_captured', 'too_broad', 'not_useful', 'other'];
|
|
15
|
+
const PROJECT_MEMORY_SUGGESTION_RECOMMENDATION_ACTIONS = ['create', 'update', 'supersede'];
|
|
16
|
+
const PROJECT_MEMORY_SUGGESTION_CONFIDENCES = ['low', 'medium', 'high'];
|
|
17
|
+
const PROJECT_MEMORY_SUGGESTION_FINGERPRINT_VERSION = 'v1';
|
|
18
|
+
const PROJECT_MEMORY_SUGGESTION_DISMISSAL_COOLDOWN_DAYS = 90;
|
|
12
19
|
const PROJECT_CONTEXT_SCAN_STATUSES = ['pending', 'running', 'done', 'error'];
|
|
13
20
|
const PROJECT_CONTEXT_ARTIFACT_KINDS = ['architecture', 'key_areas', 'conventions', 'risks'];
|
|
14
21
|
const ACCEPTED_PROJECT_DECISION_STATUS = 'accepted';
|
|
@@ -77,6 +84,8 @@ const ACCEPTED_MEMORY_COMPARISON_STOPWORDS = new Set([
|
|
|
77
84
|
'with',
|
|
78
85
|
'without',
|
|
79
86
|
]);
|
|
87
|
+
class ProjectMemoryLifecycleConflictError extends Error {
|
|
88
|
+
}
|
|
80
89
|
const parseStringArray = (value) => {
|
|
81
90
|
if (!value) {
|
|
82
91
|
return [];
|
|
@@ -178,28 +187,40 @@ const normalizeOptionalString = (value, fieldName) => {
|
|
|
178
187
|
return { values: null };
|
|
179
188
|
}
|
|
180
189
|
if (typeof value !== 'string') {
|
|
181
|
-
return { error: `${fieldName} must be a string
|
|
190
|
+
return { error: `${fieldName} must be a string`, code: 'invalid_field', field: fieldName };
|
|
182
191
|
}
|
|
183
192
|
const trimmed = value.trim();
|
|
184
193
|
return { values: trimmed === '' ? null : trimmed };
|
|
185
194
|
};
|
|
186
195
|
const validateRequiredString = (value, fieldName) => {
|
|
187
196
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
188
|
-
return { error: `${fieldName} must be a non-empty string
|
|
197
|
+
return { error: `${fieldName} must be a non-empty string`, code: 'invalid_field', field: fieldName };
|
|
189
198
|
}
|
|
190
199
|
return { values: value.trim() };
|
|
191
200
|
};
|
|
201
|
+
const validateMemoryString = (result, field, limit) => {
|
|
202
|
+
if ('error' in result) {
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
if (result.values !== null && result.values.length > limit) {
|
|
206
|
+
return { error: `${field} must not exceed ${limit} characters`, code: 'field_too_large', field, limit };
|
|
207
|
+
}
|
|
208
|
+
if (result.values !== null && containsReservedProjectMemoryControlMarker(result.values)) {
|
|
209
|
+
return { error: `${field} must not contain a reserved control marker`, code: 'reserved_control_marker', field };
|
|
210
|
+
}
|
|
211
|
+
return result;
|
|
212
|
+
};
|
|
192
213
|
const validateStages = (value) => {
|
|
193
214
|
if (value == null) {
|
|
194
215
|
return { values: null };
|
|
195
216
|
}
|
|
196
217
|
if (!Array.isArray(value)) {
|
|
197
|
-
return { error: 'stages must be an array of valid project memory stages' };
|
|
218
|
+
return { error: 'stages must be an array of valid project memory stages', code: 'invalid_field', field: 'stages' };
|
|
198
219
|
}
|
|
199
220
|
const normalized = [];
|
|
200
221
|
for (const stage of value) {
|
|
201
222
|
if (!PROJECT_MEMORY_STAGES.includes(stage)) {
|
|
202
|
-
return { error: 'stages must be an array of valid project memory stages' };
|
|
223
|
+
return { error: 'stages must be an array of valid project memory stages', code: 'invalid_field', field: 'stages' };
|
|
203
224
|
}
|
|
204
225
|
if (!normalized.includes(stage)) {
|
|
205
226
|
normalized.push(stage);
|
|
@@ -207,15 +228,40 @@ const validateStages = (value) => {
|
|
|
207
228
|
}
|
|
208
229
|
return { values: normalized.length > 0 ? JSON.stringify(normalized) : null };
|
|
209
230
|
};
|
|
231
|
+
const validateApplicabilityStrings = (value, field) => {
|
|
232
|
+
if (value == null) {
|
|
233
|
+
return { values: null };
|
|
234
|
+
}
|
|
235
|
+
if (!Array.isArray(value)) {
|
|
236
|
+
return { error: `${field} must be an array of non-empty strings`, code: 'invalid_field', field };
|
|
237
|
+
}
|
|
238
|
+
if (value.length > PROJECT_MEMORY_LIMITS.applicabilityItems) {
|
|
239
|
+
return { error: `${field} must not contain more than ${PROJECT_MEMORY_LIMITS.applicabilityItems} items`, code: 'field_too_large', field, limit: PROJECT_MEMORY_LIMITS.applicabilityItems };
|
|
240
|
+
}
|
|
241
|
+
const normalized = [];
|
|
242
|
+
for (const entry of value) {
|
|
243
|
+
if (typeof entry !== 'string' || entry.trim() === '') {
|
|
244
|
+
return { error: `${field} must be an array of non-empty strings`, code: 'invalid_field', field };
|
|
245
|
+
}
|
|
246
|
+
const result = validateMemoryString({ values: entry.trim() }, field, PROJECT_MEMORY_LIMITS.applicabilityItem);
|
|
247
|
+
if ('error' in result) {
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
if (!normalized.includes(result.values)) {
|
|
251
|
+
normalized.push(result.values);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { values: normalized.length > 0 ? JSON.stringify(normalized) : null };
|
|
255
|
+
};
|
|
210
256
|
const validatePriority = (value) => {
|
|
211
257
|
if (!PROJECT_CONVENTION_PRIORITIES.includes(value)) {
|
|
212
|
-
return { error: 'priority must be one of critical, normal, or low' };
|
|
258
|
+
return { error: 'priority must be one of critical, normal, or low', code: 'invalid_field', field: 'priority' };
|
|
213
259
|
}
|
|
214
260
|
return { values: value };
|
|
215
261
|
};
|
|
216
262
|
const validateConventionInjectionMode = (value) => {
|
|
217
263
|
if (!PROJECT_CONVENTION_INJECTION_MODES.includes(value)) {
|
|
218
|
-
return { error: 'injection_mode must be one of always or relevant' };
|
|
264
|
+
return { error: 'injection_mode must be one of always or relevant', code: 'invalid_field', field: 'injection_mode' };
|
|
219
265
|
}
|
|
220
266
|
return { values: value };
|
|
221
267
|
};
|
|
@@ -224,20 +270,125 @@ const validateImplications = (value) => {
|
|
|
224
270
|
return { values: null };
|
|
225
271
|
}
|
|
226
272
|
if (!Array.isArray(value)) {
|
|
227
|
-
return { error: 'implications must be an array of non-empty strings' };
|
|
273
|
+
return { error: 'implications must be an array of non-empty strings', code: 'invalid_field', field: 'implications' };
|
|
274
|
+
}
|
|
275
|
+
if (value.length > PROJECT_MEMORY_LIMITS.implications) {
|
|
276
|
+
return { error: `implications must not contain more than ${PROJECT_MEMORY_LIMITS.implications} items`, code: 'field_too_large', field: 'implications', limit: PROJECT_MEMORY_LIMITS.implications };
|
|
228
277
|
}
|
|
229
278
|
const normalized = [];
|
|
230
279
|
for (const implication of value) {
|
|
231
280
|
if (typeof implication !== 'string' || implication.trim() === '') {
|
|
232
|
-
return { error: 'implications must be an array of non-empty strings' };
|
|
281
|
+
return { error: 'implications must be an array of non-empty strings', code: 'invalid_field', field: 'implications' };
|
|
233
282
|
}
|
|
234
283
|
const trimmed = implication.trim();
|
|
284
|
+
const validation = validateMemoryString({ values: trimmed }, 'implications', PROJECT_MEMORY_LIMITS.implication);
|
|
285
|
+
if ('error' in validation) {
|
|
286
|
+
return validation;
|
|
287
|
+
}
|
|
235
288
|
if (!normalized.includes(trimmed)) {
|
|
236
289
|
normalized.push(trimmed);
|
|
237
290
|
}
|
|
238
291
|
}
|
|
239
292
|
return { values: normalized.length > 0 ? JSON.stringify(normalized) : null };
|
|
240
293
|
};
|
|
294
|
+
const createManualProjectMemoryOrigin = () => ({
|
|
295
|
+
type: 'manual',
|
|
296
|
+
suggestion_id: null,
|
|
297
|
+
source_stage: null,
|
|
298
|
+
source_type: null,
|
|
299
|
+
source_label: null,
|
|
300
|
+
ticket_id: null,
|
|
301
|
+
chat_session_id: null,
|
|
302
|
+
spec_session_id: null,
|
|
303
|
+
suggested_at: null,
|
|
304
|
+
accepted_at: new Date().toISOString(),
|
|
305
|
+
});
|
|
306
|
+
const getProjectMemoryOriginSourceAvailable = (projectId, origin) => {
|
|
307
|
+
if (origin.source_type === 'ticket' && origin.ticket_id) {
|
|
308
|
+
return Boolean(db.prepare('SELECT 1 FROM tickets WHERE id = ? AND project_id = ?').get(origin.ticket_id, projectId));
|
|
309
|
+
}
|
|
310
|
+
if (origin.source_type === 'chat_session' && origin.chat_session_id) {
|
|
311
|
+
return Boolean(db.prepare('SELECT 1 FROM chat_sessions WHERE id = ? AND project_id = ?').get(origin.chat_session_id, projectId));
|
|
312
|
+
}
|
|
313
|
+
if (origin.source_type === 'spec_session' && origin.spec_session_id) {
|
|
314
|
+
return Boolean(db.prepare('SELECT 1 FROM spec_sessions WHERE id = ? AND project_id = ?').get(origin.spec_session_id, projectId));
|
|
315
|
+
}
|
|
316
|
+
return false;
|
|
317
|
+
};
|
|
318
|
+
const parseProjectMemoryOrigin = (projectId, originJson, createdAt) => {
|
|
319
|
+
const manualOrigin = {
|
|
320
|
+
...createManualProjectMemoryOrigin(),
|
|
321
|
+
accepted_at: createdAt,
|
|
322
|
+
source_available: false,
|
|
323
|
+
};
|
|
324
|
+
if (!originJson) {
|
|
325
|
+
return manualOrigin;
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
const value = JSON.parse(originJson);
|
|
329
|
+
if (value.type !== 'agent_suggestion') {
|
|
330
|
+
return manualOrigin;
|
|
331
|
+
}
|
|
332
|
+
const sourceType = value.source_type === 'ticket' || value.source_type === 'chat_session' || value.source_type === 'spec_session'
|
|
333
|
+
? value.source_type
|
|
334
|
+
: null;
|
|
335
|
+
const origin = {
|
|
336
|
+
type: 'agent_suggestion',
|
|
337
|
+
suggestion_id: typeof value.suggestion_id === 'string' ? value.suggestion_id : null,
|
|
338
|
+
source_stage: PROJECT_MEMORY_STAGES.includes(value.source_stage) ? value.source_stage : null,
|
|
339
|
+
source_type: sourceType,
|
|
340
|
+
source_label: typeof value.source_label === 'string' ? value.source_label : null,
|
|
341
|
+
ticket_id: typeof value.ticket_id === 'string' ? value.ticket_id : null,
|
|
342
|
+
chat_session_id: typeof value.chat_session_id === 'string' ? value.chat_session_id : null,
|
|
343
|
+
spec_session_id: typeof value.spec_session_id === 'string' ? value.spec_session_id : null,
|
|
344
|
+
suggested_at: typeof value.suggested_at === 'string' ? value.suggested_at : null,
|
|
345
|
+
accepted_at: typeof value.accepted_at === 'string' ? value.accepted_at : createdAt,
|
|
346
|
+
};
|
|
347
|
+
return {
|
|
348
|
+
...origin,
|
|
349
|
+
source_available: getProjectMemoryOriginSourceAvailable(projectId, origin),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return manualOrigin;
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
const createProjectMemorySuggestionOrigin = (projectId, suggestion) => {
|
|
357
|
+
const source = suggestion.ticket_id
|
|
358
|
+
? {
|
|
359
|
+
type: 'ticket',
|
|
360
|
+
id: suggestion.ticket_id,
|
|
361
|
+
row: db.prepare('SELECT title FROM tickets WHERE id = ? AND project_id = ?').get(suggestion.ticket_id, projectId),
|
|
362
|
+
}
|
|
363
|
+
: suggestion.chat_session_id
|
|
364
|
+
? {
|
|
365
|
+
type: 'chat_session',
|
|
366
|
+
id: suggestion.chat_session_id,
|
|
367
|
+
row: db.prepare('SELECT title FROM chat_sessions WHERE id = ? AND project_id = ?').get(suggestion.chat_session_id, projectId),
|
|
368
|
+
}
|
|
369
|
+
: suggestion.spec_session_id
|
|
370
|
+
? {
|
|
371
|
+
type: 'spec_session',
|
|
372
|
+
id: suggestion.spec_session_id,
|
|
373
|
+
row: db.prepare('SELECT title FROM spec_sessions WHERE id = ? AND project_id = ?').get(suggestion.spec_session_id, projectId),
|
|
374
|
+
}
|
|
375
|
+
: null;
|
|
376
|
+
if (!source?.row) {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
type: 'agent_suggestion',
|
|
381
|
+
suggestion_id: suggestion.id,
|
|
382
|
+
source_stage: suggestion.source_stage,
|
|
383
|
+
source_type: source.type,
|
|
384
|
+
source_label: source.row.title,
|
|
385
|
+
ticket_id: source.type === 'ticket' ? source.id : null,
|
|
386
|
+
chat_session_id: source.type === 'chat_session' ? source.id : null,
|
|
387
|
+
spec_session_id: source.type === 'spec_session' ? source.id : null,
|
|
388
|
+
suggested_at: suggestion.created_at,
|
|
389
|
+
accepted_at: new Date().toISOString(),
|
|
390
|
+
};
|
|
391
|
+
};
|
|
241
392
|
const serializeConvention = (row) => {
|
|
242
393
|
return {
|
|
243
394
|
id: row.id,
|
|
@@ -247,15 +398,52 @@ const serializeConvention = (row) => {
|
|
|
247
398
|
instruction: row.instruction,
|
|
248
399
|
rationale: row.rationale,
|
|
249
400
|
stages: parseStringArray(row.stages_json).filter((stage) => PROJECT_MEMORY_STAGES.includes(stage)),
|
|
401
|
+
path_globs: parseStringArray(row.path_globs_json),
|
|
402
|
+
component_tags: parseStringArray(row.component_tags_json),
|
|
250
403
|
priority: row.priority,
|
|
251
404
|
injection_mode: PROJECT_CONVENTION_INJECTION_MODES.includes(row.injection_mode)
|
|
252
405
|
? row.injection_mode
|
|
253
406
|
: 'relevant',
|
|
254
407
|
status: row.status,
|
|
408
|
+
origin: parseProjectMemoryOrigin(row.project_id, row.origin_json, row.created_at),
|
|
255
409
|
created_at: row.created_at,
|
|
256
410
|
updated_at: row.updated_at,
|
|
257
411
|
};
|
|
258
412
|
};
|
|
413
|
+
const normalizeConflictText = (value) => String(value ?? '')
|
|
414
|
+
.normalize('NFKC')
|
|
415
|
+
.trim()
|
|
416
|
+
.toLocaleLowerCase('en-US')
|
|
417
|
+
.replace(/\s+/gu, ' ');
|
|
418
|
+
export const detectProjectMemoryConflicts = (decisions, conventions) => {
|
|
419
|
+
const conflicts = [];
|
|
420
|
+
const active = [
|
|
421
|
+
...decisions.filter(item => item.status === ACCEPTED_PROJECT_DECISION_STATUS).map(item => ({ ...item, kind: 'decision', content: item.decision })),
|
|
422
|
+
...conventions.filter(item => item.status === 'active').map(item => ({ ...item, kind: 'convention', content: item.instruction })),
|
|
423
|
+
].sort((left, right) => left.id.localeCompare(right.id));
|
|
424
|
+
for (let leftIndex = 0; leftIndex < active.length; leftIndex += 1) {
|
|
425
|
+
const left = active[leftIndex];
|
|
426
|
+
for (let rightIndex = leftIndex + 1; rightIndex < active.length; rightIndex += 1) {
|
|
427
|
+
const right = active[rightIndex];
|
|
428
|
+
if (left.kind !== right.kind || normalizeConflictText(left.title) !== normalizeConflictText(right.title) || normalizeConflictText(left.scope) !== normalizeConflictText(right.scope)) {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
const memoryIds = [left.id, right.id].sort();
|
|
432
|
+
if (left.kind === 'convention' && right.kind === 'convention' && left.priority === 'critical' && right.priority === 'critical' && left.injection_mode === 'always' && right.injection_mode === 'always') {
|
|
433
|
+
conflicts.push({ code: 'duplicate_critical_convention', memory_ids: memoryIds, message: 'Critical always-on conventions share the same title and scope.' });
|
|
434
|
+
}
|
|
435
|
+
else if (normalizeConflictText(left.content) !== normalizeConflictText(right.content)) {
|
|
436
|
+
conflicts.push({ code: 'different_content', memory_ids: memoryIds, message: 'Active memories share the same title and scope but have different content.' });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
for (const decision of decisions.filter(item => item.status === ACCEPTED_PROJECT_DECISION_STATUS && item.superseded_by_decision_id)) {
|
|
441
|
+
if (decisions.some(candidate => candidate.id === decision.superseded_by_decision_id && candidate.status === ACCEPTED_PROJECT_DECISION_STATUS)) {
|
|
442
|
+
conflicts.push({ code: 'active_replacement', memory_ids: [decision.id, decision.superseded_by_decision_id], message: 'The replacement for this restored decision is still active.' });
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return conflicts.sort((left, right) => left.code.localeCompare(right.code) || left.memory_ids.join('\0').localeCompare(right.memory_ids.join('\0')));
|
|
446
|
+
};
|
|
259
447
|
const serializeDecision = (row) => {
|
|
260
448
|
return {
|
|
261
449
|
id: row.id,
|
|
@@ -265,7 +453,12 @@ const serializeDecision = (row) => {
|
|
|
265
453
|
decision: row.decision,
|
|
266
454
|
rationale: row.rationale,
|
|
267
455
|
implications: parseStringArray(row.implications_json),
|
|
456
|
+
stages: parseStringArray(row.stages_json).filter((stage) => PROJECT_MEMORY_STAGES.includes(stage)),
|
|
457
|
+
path_globs: parseStringArray(row.path_globs_json),
|
|
458
|
+
component_tags: parseStringArray(row.component_tags_json),
|
|
268
459
|
status: row.status,
|
|
460
|
+
superseded_by_decision_id: row.superseded_by_decision_id,
|
|
461
|
+
origin: parseProjectMemoryOrigin(row.project_id, row.origin_json, row.created_at),
|
|
269
462
|
created_at: row.created_at,
|
|
270
463
|
updated_at: row.updated_at,
|
|
271
464
|
};
|
|
@@ -276,19 +469,22 @@ export const serializeProjectMemoryFromConventionValue = (convention) => {
|
|
|
276
469
|
project_id: convention.project_id,
|
|
277
470
|
kind: 'convention',
|
|
278
471
|
state: convention.status === 'active' ? 'active' : 'archived',
|
|
279
|
-
source: 'manual',
|
|
472
|
+
source: convention.origin?.type === 'agent_suggestion' ? 'agent' : 'manual',
|
|
280
473
|
title: convention.title,
|
|
281
474
|
scope: convention.scope,
|
|
282
475
|
content: convention.instruction,
|
|
283
476
|
rationale: convention.rationale,
|
|
284
477
|
implications: [],
|
|
285
478
|
stages: convention.stages,
|
|
479
|
+
path_globs: convention.path_globs,
|
|
480
|
+
component_tags: convention.component_tags,
|
|
286
481
|
priority: convention.priority,
|
|
287
482
|
injection_mode: convention.injection_mode,
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
483
|
+
origin: convention.origin ?? parseProjectMemoryOrigin(convention.project_id, null, convention.created_at),
|
|
484
|
+
source_stage: convention.origin?.source_stage ?? null,
|
|
485
|
+
ticket_id: convention.origin?.ticket_id ?? null,
|
|
486
|
+
chat_session_id: convention.origin?.chat_session_id ?? null,
|
|
487
|
+
spec_session_id: convention.origin?.spec_session_id ?? null,
|
|
292
488
|
duplicate_match: null,
|
|
293
489
|
created_at: convention.created_at,
|
|
294
490
|
updated_at: convention.updated_at,
|
|
@@ -303,19 +499,22 @@ export const serializeProjectMemoryFromDecisionValue = (decision) => {
|
|
|
303
499
|
project_id: decision.project_id,
|
|
304
500
|
kind: 'decision',
|
|
305
501
|
state: decision.status === 'accepted' ? 'active' : decision.status,
|
|
306
|
-
source: 'manual',
|
|
502
|
+
source: decision.origin?.type === 'agent_suggestion' ? 'agent' : 'manual',
|
|
307
503
|
title: decision.title,
|
|
308
504
|
scope: decision.scope,
|
|
309
505
|
content: decision.decision,
|
|
310
506
|
rationale: decision.rationale,
|
|
311
507
|
implications: decision.implications,
|
|
312
|
-
stages:
|
|
508
|
+
stages: decision.stages,
|
|
509
|
+
path_globs: decision.path_globs,
|
|
510
|
+
component_tags: decision.component_tags,
|
|
313
511
|
priority: null,
|
|
314
512
|
injection_mode: null,
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
513
|
+
origin: decision.origin ?? parseProjectMemoryOrigin(decision.project_id, null, decision.created_at),
|
|
514
|
+
source_stage: decision.origin?.source_stage ?? null,
|
|
515
|
+
ticket_id: decision.origin?.ticket_id ?? null,
|
|
516
|
+
chat_session_id: decision.origin?.chat_session_id ?? null,
|
|
517
|
+
spec_session_id: decision.origin?.spec_session_id ?? null,
|
|
319
518
|
duplicate_match: null,
|
|
320
519
|
created_at: decision.created_at,
|
|
321
520
|
updated_at: decision.updated_at,
|
|
@@ -336,6 +535,7 @@ const parseJsonValue = (value) => {
|
|
|
336
535
|
}
|
|
337
536
|
};
|
|
338
537
|
const serializeProjectContextScan = (row) => {
|
|
538
|
+
const part8Row = row;
|
|
339
539
|
return {
|
|
340
540
|
id: row.id,
|
|
341
541
|
project_id: row.project_id,
|
|
@@ -344,6 +544,12 @@ const serializeProjectContextScan = (row) => {
|
|
|
344
544
|
: 'error',
|
|
345
545
|
repo_head: row.repo_head,
|
|
346
546
|
repo_branch: row.repo_branch,
|
|
547
|
+
scanned_at: part8Row.scanned_at === undefined ? row.created_at : part8Row.scanned_at,
|
|
548
|
+
completed_at: part8Row.completed_at === undefined && row.status === 'done' ? row.updated_at : part8Row.completed_at ?? null,
|
|
549
|
+
working_tree_dirty: part8Row.working_tree_dirty == null ? null : Boolean(part8Row.working_tree_dirty),
|
|
550
|
+
manifest: parseJsonValue(part8Row.manifest_json),
|
|
551
|
+
scanner_provider: part8Row.scanner_provider ?? null,
|
|
552
|
+
origin: part8Row.origin === 'archive_import' ? 'archive_import' : 'local_scan',
|
|
347
553
|
scanner_tool: row.scanner_tool === 'claude' || row.scanner_tool === 'opencode' ? row.scanner_tool : null,
|
|
348
554
|
scanner_model: row.scanner_model,
|
|
349
555
|
scanner_variant: row.scanner_variant,
|
|
@@ -383,6 +589,14 @@ const parseProjectMemorySuggestionDetails = (value) => {
|
|
|
383
589
|
};
|
|
384
590
|
const parseProjectMemorySuggestionValues = (row) => {
|
|
385
591
|
const details = parseProjectMemorySuggestionDetails(row.details_json);
|
|
592
|
+
const evidence = details.evidence && typeof details.evidence === 'object' && !Array.isArray(details.evidence)
|
|
593
|
+
? details.evidence
|
|
594
|
+
: null;
|
|
595
|
+
const recommendation = details.recommendation && typeof details.recommendation === 'object' && !Array.isArray(details.recommendation)
|
|
596
|
+
? details.recommendation
|
|
597
|
+
: null;
|
|
598
|
+
const pathGlobs = Array.isArray(details.path_globs) ? details.path_globs.filter((value) => typeof value === 'string') : [];
|
|
599
|
+
const componentTags = Array.isArray(details.component_tags) ? details.component_tags.filter((value) => typeof value === 'string') : [];
|
|
386
600
|
if (row.kind === 'decision') {
|
|
387
601
|
const implications = Array.isArray(details.implications)
|
|
388
602
|
? details.implications
|
|
@@ -398,6 +612,11 @@ const parseProjectMemorySuggestionValues = (row) => {
|
|
|
398
612
|
decision: row.content_text,
|
|
399
613
|
rationale: row.rationale,
|
|
400
614
|
implications,
|
|
615
|
+
stages: Array.isArray(details.stages) ? details.stages.filter((value) => PROJECT_MEMORY_STAGES.includes(value)) : [],
|
|
616
|
+
path_globs: pathGlobs,
|
|
617
|
+
component_tags: componentTags,
|
|
618
|
+
evidence,
|
|
619
|
+
recommendation,
|
|
401
620
|
};
|
|
402
621
|
}
|
|
403
622
|
if (row.kind === 'convention') {
|
|
@@ -421,6 +640,10 @@ const parseProjectMemorySuggestionValues = (row) => {
|
|
|
421
640
|
stages,
|
|
422
641
|
priority,
|
|
423
642
|
injection_mode: injectionMode,
|
|
643
|
+
path_globs: pathGlobs,
|
|
644
|
+
component_tags: componentTags,
|
|
645
|
+
evidence,
|
|
646
|
+
recommendation,
|
|
424
647
|
};
|
|
425
648
|
}
|
|
426
649
|
return null;
|
|
@@ -434,15 +657,21 @@ const getProjectMemorySuggestionTextParts = (suggestion) => {
|
|
|
434
657
|
export const getProjectMemorySuggestionDedupeKey = (suggestion) => {
|
|
435
658
|
const baseParts = [
|
|
436
659
|
suggestion.kind,
|
|
437
|
-
suggestion.title.
|
|
438
|
-
suggestion.scope?.
|
|
439
|
-
getProjectMemorySuggestionContent(suggestion).
|
|
660
|
+
suggestion.title.normalize('NFKC').toLocaleLowerCase('en-US').replace(/\s+/gu, ' ').trim(),
|
|
661
|
+
suggestion.scope?.normalize('NFKC').toLocaleLowerCase('en-US').replace(/\s+/gu, ' ').trim() ?? '',
|
|
662
|
+
getProjectMemorySuggestionContent(suggestion).normalize('NFKC').toLocaleLowerCase('en-US').replace(/\s+/gu, ' ').trim(),
|
|
663
|
+
(suggestion.stages ?? []).slice().sort().join(','),
|
|
664
|
+
(suggestion.path_globs ?? []).map(value => value.normalize('NFKC').toLocaleLowerCase('en-US')).sort().join(','),
|
|
665
|
+
(suggestion.component_tags ?? []).map(value => value.normalize('NFKC').toLocaleLowerCase('en-US')).sort().join(','),
|
|
440
666
|
];
|
|
441
667
|
if (suggestion.kind === 'convention') {
|
|
442
|
-
baseParts.push(suggestion.
|
|
668
|
+
baseParts.push(suggestion.priority, suggestion.injection_mode);
|
|
443
669
|
}
|
|
444
670
|
return baseParts.join('::');
|
|
445
671
|
};
|
|
672
|
+
export const getProjectMemorySuggestionFingerprint = (projectId, suggestion) => {
|
|
673
|
+
return `${PROJECT_MEMORY_SUGGESTION_FINGERPRINT_VERSION}:${createHash('sha256').update(`${projectId}\0${getProjectMemorySuggestionDedupeKey(suggestion)}`).digest('hex')}`;
|
|
674
|
+
};
|
|
446
675
|
const serializeProjectMemorySuggestion = (row) => {
|
|
447
676
|
const values = parseProjectMemorySuggestionValues(row);
|
|
448
677
|
if (!values) {
|
|
@@ -468,7 +697,14 @@ const serializeProjectMemorySuggestion = (row) => {
|
|
|
468
697
|
decision: values.decision,
|
|
469
698
|
rationale: values.rationale,
|
|
470
699
|
implications: values.implications,
|
|
700
|
+
stages: values.stages ?? [],
|
|
701
|
+
path_globs: values.path_globs ?? [],
|
|
702
|
+
component_tags: values.component_tags ?? [],
|
|
703
|
+
evidence: values.evidence ?? null,
|
|
704
|
+
recommendation: values.recommendation ?? null,
|
|
705
|
+
dismissal_reason: PROJECT_MEMORY_SUGGESTION_DISMISSAL_REASONS.includes(row.dismissal_reason) ? row.dismissal_reason : null,
|
|
471
706
|
status: row.status,
|
|
707
|
+
resolved_memory_id: row.resolved_memory_id,
|
|
472
708
|
duplicate_match: duplicateDecision
|
|
473
709
|
? {
|
|
474
710
|
type: 'near_duplicate',
|
|
@@ -497,7 +733,13 @@ const serializeProjectMemorySuggestion = (row) => {
|
|
|
497
733
|
stages: values.stages,
|
|
498
734
|
priority: values.priority,
|
|
499
735
|
injection_mode: values.injection_mode,
|
|
736
|
+
path_globs: values.path_globs ?? [],
|
|
737
|
+
component_tags: values.component_tags ?? [],
|
|
738
|
+
evidence: values.evidence ?? null,
|
|
739
|
+
recommendation: values.recommendation ?? null,
|
|
740
|
+
dismissal_reason: PROJECT_MEMORY_SUGGESTION_DISMISSAL_REASONS.includes(row.dismissal_reason) ? row.dismissal_reason : null,
|
|
500
741
|
status: row.status,
|
|
742
|
+
resolved_memory_id: row.resolved_memory_id,
|
|
501
743
|
duplicate_match: duplicateConvention
|
|
502
744
|
? {
|
|
503
745
|
type: 'near_duplicate',
|
|
@@ -529,9 +771,12 @@ export const serializeProjectMemoryFromSuggestionValue = (suggestion) => {
|
|
|
529
771
|
content: suggestion.decision,
|
|
530
772
|
rationale: suggestion.rationale,
|
|
531
773
|
implications: suggestion.implications,
|
|
532
|
-
stages:
|
|
774
|
+
stages: suggestion.stages,
|
|
775
|
+
path_globs: suggestion.path_globs,
|
|
776
|
+
component_tags: suggestion.component_tags,
|
|
533
777
|
priority: null,
|
|
534
778
|
injection_mode: null,
|
|
779
|
+
origin: null,
|
|
535
780
|
source_stage: suggestion.source_stage,
|
|
536
781
|
ticket_id: suggestion.ticket_id,
|
|
537
782
|
chat_session_id: suggestion.chat_session_id,
|
|
@@ -561,8 +806,11 @@ export const serializeProjectMemoryFromSuggestionValue = (suggestion) => {
|
|
|
561
806
|
rationale: suggestion.rationale,
|
|
562
807
|
implications: [],
|
|
563
808
|
stages: suggestion.stages,
|
|
809
|
+
path_globs: suggestion.path_globs,
|
|
810
|
+
component_tags: suggestion.component_tags,
|
|
564
811
|
priority: suggestion.priority,
|
|
565
812
|
injection_mode: suggestion.injection_mode,
|
|
813
|
+
origin: null,
|
|
566
814
|
source_stage: suggestion.source_stage,
|
|
567
815
|
ticket_id: suggestion.ticket_id,
|
|
568
816
|
chat_session_id: suggestion.chat_session_id,
|
|
@@ -637,14 +885,20 @@ export const createProjectContextScan = (projectId, values) => {
|
|
|
637
885
|
status,
|
|
638
886
|
repo_head,
|
|
639
887
|
repo_branch,
|
|
888
|
+
scanned_at,
|
|
889
|
+
completed_at,
|
|
890
|
+
working_tree_dirty,
|
|
891
|
+
manifest_json,
|
|
892
|
+
scanner_provider,
|
|
893
|
+
origin,
|
|
640
894
|
scanner_tool,
|
|
641
895
|
scanner_model,
|
|
642
896
|
scanner_variant,
|
|
643
897
|
summary_markdown,
|
|
644
898
|
error
|
|
645
899
|
)
|
|
646
|
-
VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, NULL, NULL)
|
|
647
|
-
`).run(id, projectId, values.repo_head, values.repo_branch, values.scanner_tool, values.scanner_model, values.scanner_variant);
|
|
900
|
+
VALUES (?, ?, 'pending', ?, ?, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?, NULL, NULL)
|
|
901
|
+
`).run(id, projectId, values.repo_head, values.repo_branch, values.scanner_provider, values.origin, values.scanner_tool, values.scanner_model, values.scanner_variant);
|
|
648
902
|
return getProjectContextScanById(projectId, id);
|
|
649
903
|
};
|
|
650
904
|
export const updateProjectContextScan = (projectId, scanId, values) => {
|
|
@@ -657,6 +911,12 @@ export const updateProjectContextScan = (projectId, scanId, values) => {
|
|
|
657
911
|
SET status = ?,
|
|
658
912
|
repo_head = ?,
|
|
659
913
|
repo_branch = ?,
|
|
914
|
+
scanned_at = ?,
|
|
915
|
+
completed_at = ?,
|
|
916
|
+
working_tree_dirty = ?,
|
|
917
|
+
manifest_json = ?,
|
|
918
|
+
scanner_provider = ?,
|
|
919
|
+
origin = ?,
|
|
660
920
|
scanner_tool = ?,
|
|
661
921
|
scanner_model = ?,
|
|
662
922
|
scanner_variant = ?,
|
|
@@ -665,7 +925,7 @@ export const updateProjectContextScan = (projectId, scanId, values) => {
|
|
|
665
925
|
updated_at = CURRENT_TIMESTAMP
|
|
666
926
|
WHERE id = ?
|
|
667
927
|
AND project_id = ?
|
|
668
|
-
`).run(values.status ?? current.status, Object.hasOwn(values, 'repo_head') ? values.repo_head : current.repo_head, Object.hasOwn(values, 'repo_branch') ? values.repo_branch : current.repo_branch, Object.hasOwn(values, 'scanner_tool') ? values.scanner_tool : current.scanner_tool, Object.hasOwn(values, 'scanner_model') ? values.scanner_model : current.scanner_model, Object.hasOwn(values, 'scanner_variant') ? values.scanner_variant : current.scanner_variant, Object.hasOwn(values, 'summary_markdown') ? values.summary_markdown : current.summary_markdown, Object.hasOwn(values, 'error') ? values.error : current.error, scanId, projectId);
|
|
928
|
+
`).run(values.status ?? current.status, Object.hasOwn(values, 'repo_head') ? values.repo_head : current.repo_head, Object.hasOwn(values, 'repo_branch') ? values.repo_branch : current.repo_branch, Object.hasOwn(values, 'scanned_at') ? values.scanned_at : current.scanned_at, Object.hasOwn(values, 'completed_at') ? values.completed_at : current.completed_at, Object.hasOwn(values, 'working_tree_dirty') ? values.working_tree_dirty == null ? null : Number(values.working_tree_dirty) : (current.working_tree_dirty == null ? null : Number(current.working_tree_dirty)), Object.hasOwn(values, 'manifest') ? JSON.stringify(values.manifest) : JSON.stringify(current.manifest ?? null), Object.hasOwn(values, 'scanner_provider') ? values.scanner_provider : current.scanner_provider, Object.hasOwn(values, 'origin') ? values.origin : current.origin ?? 'local_scan', Object.hasOwn(values, 'scanner_tool') ? values.scanner_tool : current.scanner_tool, Object.hasOwn(values, 'scanner_model') ? values.scanner_model : current.scanner_model, Object.hasOwn(values, 'scanner_variant') ? values.scanner_variant : current.scanner_variant, Object.hasOwn(values, 'summary_markdown') ? values.summary_markdown : current.summary_markdown, Object.hasOwn(values, 'error') ? values.error : current.error, scanId, projectId);
|
|
669
929
|
return getProjectContextScanById(projectId, scanId);
|
|
670
930
|
};
|
|
671
931
|
export const replaceProjectContextArtifacts = (projectId, scanId, artifacts) => {
|
|
@@ -691,6 +951,38 @@ export const replaceProjectContextArtifacts = (projectId, scanId, artifacts) =>
|
|
|
691
951
|
transaction(artifacts);
|
|
692
952
|
return listProjectContextArtifactsForScan(projectId, scanId);
|
|
693
953
|
};
|
|
954
|
+
export const completeProjectContextScan = (projectId, scanId, values) => {
|
|
955
|
+
const removeArtifacts = db.prepare('DELETE FROM project_context_artifacts WHERE scan_id = ? AND project_id = ?');
|
|
956
|
+
const insertArtifact = db.prepare(`
|
|
957
|
+
INSERT INTO project_context_artifacts (
|
|
958
|
+
id, scan_id, project_id, kind, title, content_json, content_markdown
|
|
959
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
960
|
+
`);
|
|
961
|
+
const completeScan = db.prepare(`
|
|
962
|
+
UPDATE project_context_scans
|
|
963
|
+
SET status = 'done',
|
|
964
|
+
summary_markdown = ?,
|
|
965
|
+
completed_at = ?,
|
|
966
|
+
manifest_json = ?,
|
|
967
|
+
error = NULL,
|
|
968
|
+
updated_at = CURRENT_TIMESTAMP
|
|
969
|
+
WHERE id = ?
|
|
970
|
+
AND project_id = ?
|
|
971
|
+
AND status = 'running'
|
|
972
|
+
`);
|
|
973
|
+
const transaction = db.transaction(() => {
|
|
974
|
+
removeArtifacts.run(scanId, projectId);
|
|
975
|
+
for (const artifact of values.artifacts) {
|
|
976
|
+
insertArtifact.run(randomUUID(), scanId, projectId, artifact.kind, artifact.title, artifact.content_json, artifact.content_markdown);
|
|
977
|
+
}
|
|
978
|
+
const completed = completeScan.run(values.summary_markdown, values.completed_at, JSON.stringify(values.manifest), scanId, projectId);
|
|
979
|
+
if (completed.changes !== 1) {
|
|
980
|
+
throw new Error('The project context scan could not be completed from its current state.');
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
transaction();
|
|
984
|
+
return getProjectContextScanById(projectId, scanId);
|
|
985
|
+
};
|
|
694
986
|
export const listProjectContextArtifactsForScan = (projectId, scanId) => {
|
|
695
987
|
const rows = db.prepare(`
|
|
696
988
|
SELECT *
|
|
@@ -708,28 +1000,51 @@ export const listLatestSuccessfulProjectContextArtifacts = (projectId) => {
|
|
|
708
1000
|
}
|
|
709
1001
|
return listProjectContextArtifactsForScan(projectId, scan.id);
|
|
710
1002
|
};
|
|
1003
|
+
export const listProjectContextScans = (projectId, { limit, cursor = null }) => {
|
|
1004
|
+
const boundedLimit = Math.max(1, Math.min(limit, 100));
|
|
1005
|
+
const rows = db.prepare(`
|
|
1006
|
+
SELECT *
|
|
1007
|
+
FROM project_context_scans
|
|
1008
|
+
WHERE project_id = ?
|
|
1009
|
+
AND (
|
|
1010
|
+
? IS NULL
|
|
1011
|
+
OR created_at < ?
|
|
1012
|
+
OR (created_at = ? AND id < ?)
|
|
1013
|
+
)
|
|
1014
|
+
ORDER BY created_at DESC, id DESC
|
|
1015
|
+
LIMIT ?
|
|
1016
|
+
`).all(projectId, cursor?.createdAt ?? null, cursor?.createdAt ?? null, cursor?.createdAt ?? null, cursor?.id ?? null, boundedLimit + 1);
|
|
1017
|
+
const pageRows = rows.slice(0, boundedLimit);
|
|
1018
|
+
const lastRow = pageRows.at(-1);
|
|
1019
|
+
return {
|
|
1020
|
+
scans: pageRows.map(serializeProjectContextScan),
|
|
1021
|
+
nextCursor: rows.length > boundedLimit && lastRow
|
|
1022
|
+
? encodeProjectMemoryCursor({ createdAt: lastRow.created_at, id: lastRow.id })
|
|
1023
|
+
: null,
|
|
1024
|
+
};
|
|
1025
|
+
};
|
|
711
1026
|
export const validateProjectConventionPayload = (body, { partial = false } = {}) => {
|
|
712
1027
|
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
713
|
-
return { error: 'Invalid convention payload' };
|
|
1028
|
+
return { error: 'Invalid convention payload', code: 'invalid_field' };
|
|
714
1029
|
}
|
|
715
1030
|
const input = body;
|
|
716
1031
|
const values = {};
|
|
717
1032
|
if (!partial || Object.hasOwn(input, 'title')) {
|
|
718
|
-
const result = validateRequiredString(input.title, 'title');
|
|
1033
|
+
const result = validateMemoryString(validateRequiredString(input.title, 'title'), 'title', PROJECT_MEMORY_LIMITS.title);
|
|
719
1034
|
if ('error' in result) {
|
|
720
1035
|
return result;
|
|
721
1036
|
}
|
|
722
1037
|
values.title = result.values;
|
|
723
1038
|
}
|
|
724
1039
|
if (!partial || Object.hasOwn(input, 'instruction')) {
|
|
725
|
-
const result = validateRequiredString(input.instruction, 'instruction');
|
|
1040
|
+
const result = validateMemoryString(validateRequiredString(input.instruction, 'instruction'), 'instruction', PROJECT_MEMORY_LIMITS.content);
|
|
726
1041
|
if ('error' in result) {
|
|
727
1042
|
return result;
|
|
728
1043
|
}
|
|
729
1044
|
values.instruction = result.values;
|
|
730
1045
|
}
|
|
731
1046
|
if (Object.hasOwn(input, 'scope')) {
|
|
732
|
-
const result = normalizeOptionalString(input.scope, 'scope');
|
|
1047
|
+
const result = validateMemoryString(normalizeOptionalString(input.scope, 'scope'), 'scope', PROJECT_MEMORY_LIMITS.scope);
|
|
733
1048
|
if ('error' in result) {
|
|
734
1049
|
return result;
|
|
735
1050
|
}
|
|
@@ -739,7 +1054,7 @@ export const validateProjectConventionPayload = (body, { partial = false } = {})
|
|
|
739
1054
|
values.scope = null;
|
|
740
1055
|
}
|
|
741
1056
|
if (Object.hasOwn(input, 'rationale')) {
|
|
742
|
-
const result = normalizeOptionalString(input.rationale, 'rationale');
|
|
1057
|
+
const result = validateMemoryString(normalizeOptionalString(input.rationale, 'rationale'), 'rationale', PROJECT_MEMORY_LIMITS.rationale);
|
|
743
1058
|
if ('error' in result) {
|
|
744
1059
|
return result;
|
|
745
1060
|
}
|
|
@@ -758,6 +1073,18 @@ export const validateProjectConventionPayload = (body, { partial = false } = {})
|
|
|
758
1073
|
else if (!partial) {
|
|
759
1074
|
values.stages_json = null;
|
|
760
1075
|
}
|
|
1076
|
+
for (const field of ['path_globs', 'component_tags']) {
|
|
1077
|
+
if (Object.hasOwn(input, field)) {
|
|
1078
|
+
const result = validateApplicabilityStrings(input[field], field);
|
|
1079
|
+
if ('error' in result) {
|
|
1080
|
+
return result;
|
|
1081
|
+
}
|
|
1082
|
+
values[`${field}_json`] = result.values;
|
|
1083
|
+
}
|
|
1084
|
+
else if (!partial) {
|
|
1085
|
+
values[`${field}_json`] = null;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
761
1088
|
if (Object.hasOwn(input, 'priority')) {
|
|
762
1089
|
const result = validatePriority(input.priority);
|
|
763
1090
|
if ('error' in result) {
|
|
@@ -782,26 +1109,26 @@ export const validateProjectConventionPayload = (body, { partial = false } = {})
|
|
|
782
1109
|
};
|
|
783
1110
|
export const validateProjectDecisionPayload = (body, { partial = false } = {}) => {
|
|
784
1111
|
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
785
|
-
return { error: 'Invalid decision payload' };
|
|
1112
|
+
return { error: 'Invalid decision payload', code: 'invalid_field' };
|
|
786
1113
|
}
|
|
787
1114
|
const input = body;
|
|
788
1115
|
const values = {};
|
|
789
1116
|
if (!partial || Object.hasOwn(input, 'title')) {
|
|
790
|
-
const result = validateRequiredString(input.title, 'title');
|
|
1117
|
+
const result = validateMemoryString(validateRequiredString(input.title, 'title'), 'title', PROJECT_MEMORY_LIMITS.title);
|
|
791
1118
|
if ('error' in result) {
|
|
792
1119
|
return result;
|
|
793
1120
|
}
|
|
794
1121
|
values.title = result.values;
|
|
795
1122
|
}
|
|
796
1123
|
if (!partial || Object.hasOwn(input, 'decision')) {
|
|
797
|
-
const result = validateRequiredString(input.decision, 'decision');
|
|
1124
|
+
const result = validateMemoryString(validateRequiredString(input.decision, 'decision'), 'decision', PROJECT_MEMORY_LIMITS.content);
|
|
798
1125
|
if ('error' in result) {
|
|
799
1126
|
return result;
|
|
800
1127
|
}
|
|
801
1128
|
values.decision = result.values;
|
|
802
1129
|
}
|
|
803
1130
|
if (Object.hasOwn(input, 'scope')) {
|
|
804
|
-
const result = normalizeOptionalString(input.scope, 'scope');
|
|
1131
|
+
const result = validateMemoryString(normalizeOptionalString(input.scope, 'scope'), 'scope', PROJECT_MEMORY_LIMITS.scope);
|
|
805
1132
|
if ('error' in result) {
|
|
806
1133
|
return result;
|
|
807
1134
|
}
|
|
@@ -811,7 +1138,7 @@ export const validateProjectDecisionPayload = (body, { partial = false } = {}) =
|
|
|
811
1138
|
values.scope = null;
|
|
812
1139
|
}
|
|
813
1140
|
if (Object.hasOwn(input, 'rationale')) {
|
|
814
|
-
const result = normalizeOptionalString(input.rationale, 'rationale');
|
|
1141
|
+
const result = validateMemoryString(normalizeOptionalString(input.rationale, 'rationale'), 'rationale', PROJECT_MEMORY_LIMITS.rationale);
|
|
815
1142
|
if ('error' in result) {
|
|
816
1143
|
return result;
|
|
817
1144
|
}
|
|
@@ -830,15 +1157,78 @@ export const validateProjectDecisionPayload = (body, { partial = false } = {}) =
|
|
|
830
1157
|
else if (!partial) {
|
|
831
1158
|
values.implications_json = null;
|
|
832
1159
|
}
|
|
1160
|
+
if (Object.hasOwn(input, 'stages')) {
|
|
1161
|
+
const result = validateStages(input.stages);
|
|
1162
|
+
if ('error' in result) {
|
|
1163
|
+
return result;
|
|
1164
|
+
}
|
|
1165
|
+
values.stages_json = result.values;
|
|
1166
|
+
}
|
|
1167
|
+
else if (!partial) {
|
|
1168
|
+
values.stages_json = null;
|
|
1169
|
+
}
|
|
1170
|
+
for (const field of ['path_globs', 'component_tags']) {
|
|
1171
|
+
if (Object.hasOwn(input, field)) {
|
|
1172
|
+
const result = validateApplicabilityStrings(input[field], field);
|
|
1173
|
+
if ('error' in result) {
|
|
1174
|
+
return result;
|
|
1175
|
+
}
|
|
1176
|
+
values[`${field}_json`] = result.values;
|
|
1177
|
+
}
|
|
1178
|
+
else if (!partial) {
|
|
1179
|
+
values[`${field}_json`] = null;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
833
1182
|
return { values };
|
|
834
1183
|
};
|
|
1184
|
+
const validateProjectMemorySuggestionMetadata = (input) => {
|
|
1185
|
+
let evidence = null;
|
|
1186
|
+
if (input.evidence != null) {
|
|
1187
|
+
if (typeof input.evidence !== 'object' || Array.isArray(input.evidence)) {
|
|
1188
|
+
return { error: 'evidence must be an object', code: 'invalid_field', field: 'evidence' };
|
|
1189
|
+
}
|
|
1190
|
+
const value = input.evidence;
|
|
1191
|
+
const summary = validateMemoryString(validateRequiredString(value.summary, 'summary'), 'summary', PROJECT_MEMORY_LIMITS.evidenceSummary);
|
|
1192
|
+
if ('error' in summary) {
|
|
1193
|
+
return { ...summary, field: 'evidence.summary' };
|
|
1194
|
+
}
|
|
1195
|
+
const excerpt = validateMemoryString(normalizeOptionalString(value.source_excerpt, 'source_excerpt'), 'source_excerpt', PROJECT_MEMORY_LIMITS.evidenceExcerpt);
|
|
1196
|
+
if ('error' in excerpt) {
|
|
1197
|
+
return { ...excerpt, field: 'evidence.source_excerpt' };
|
|
1198
|
+
}
|
|
1199
|
+
evidence = { summary: summary.values, source_excerpt: excerpt.values };
|
|
1200
|
+
}
|
|
1201
|
+
let recommendation = null;
|
|
1202
|
+
if (input.recommendation != null) {
|
|
1203
|
+
if (typeof input.recommendation !== 'object' || Array.isArray(input.recommendation)) {
|
|
1204
|
+
return { error: 'recommendation must be an object', code: 'invalid_field', field: 'recommendation' };
|
|
1205
|
+
}
|
|
1206
|
+
const value = input.recommendation;
|
|
1207
|
+
if (!PROJECT_MEMORY_SUGGESTION_RECOMMENDATION_ACTIONS.includes(value.action)) {
|
|
1208
|
+
return { error: 'recommendation.action must be create, update, or supersede', code: 'invalid_field', field: 'recommendation.action' };
|
|
1209
|
+
}
|
|
1210
|
+
if (!PROJECT_MEMORY_SUGGESTION_CONFIDENCES.includes(value.confidence)) {
|
|
1211
|
+
return { error: 'recommendation.confidence must be low, medium, or high', code: 'invalid_field', field: 'recommendation.confidence' };
|
|
1212
|
+
}
|
|
1213
|
+
const targetMemoryId = value.target_memory_id == null ? null : typeof value.target_memory_id === 'string' ? value.target_memory_id.trim() || null : undefined;
|
|
1214
|
+
if (targetMemoryId === undefined) {
|
|
1215
|
+
return { error: 'recommendation.target_memory_id must be a string or null', code: 'invalid_field', field: 'recommendation.target_memory_id' };
|
|
1216
|
+
}
|
|
1217
|
+
recommendation = { action: value.action, target_memory_id: targetMemoryId, confidence: value.confidence };
|
|
1218
|
+
}
|
|
1219
|
+
return { values: { evidence, recommendation } };
|
|
1220
|
+
};
|
|
835
1221
|
export const validateProjectMemorySuggestionPayload = (body) => {
|
|
836
1222
|
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
837
|
-
return { error: 'Invalid project memory suggestion payload' };
|
|
1223
|
+
return { error: 'Invalid project memory suggestion payload', code: 'invalid_field' };
|
|
838
1224
|
}
|
|
839
1225
|
const input = body;
|
|
840
1226
|
if (!PROJECT_MEMORY_SUGGESTION_KINDS.includes(input.kind)) {
|
|
841
|
-
return { error: 'kind must be one of decision or convention' };
|
|
1227
|
+
return { error: 'kind must be one of decision or convention', code: 'invalid_field', field: 'kind' };
|
|
1228
|
+
}
|
|
1229
|
+
const metadata = validateProjectMemorySuggestionMetadata(input);
|
|
1230
|
+
if ('error' in metadata) {
|
|
1231
|
+
return metadata;
|
|
842
1232
|
}
|
|
843
1233
|
if (input.kind === 'decision') {
|
|
844
1234
|
const decisionValidation = validateProjectDecisionPayload({
|
|
@@ -847,9 +1237,12 @@ export const validateProjectMemorySuggestionPayload = (body) => {
|
|
|
847
1237
|
decision: input.decision ?? input.content,
|
|
848
1238
|
rationale: input.rationale,
|
|
849
1239
|
implications: input.implications,
|
|
1240
|
+
stages: input.stages,
|
|
1241
|
+
path_globs: input.path_globs,
|
|
1242
|
+
component_tags: input.component_tags,
|
|
850
1243
|
});
|
|
851
1244
|
if ('error' in decisionValidation) {
|
|
852
|
-
return
|
|
1245
|
+
return decisionValidation;
|
|
853
1246
|
}
|
|
854
1247
|
return {
|
|
855
1248
|
values: {
|
|
@@ -859,6 +1252,10 @@ export const validateProjectMemorySuggestionPayload = (body) => {
|
|
|
859
1252
|
decision: decisionValidation.values.decision ?? '',
|
|
860
1253
|
rationale: decisionValidation.values.rationale ?? null,
|
|
861
1254
|
implications: parseStringArray(decisionValidation.values.implications_json ?? null),
|
|
1255
|
+
stages: parseStringArray(decisionValidation.values.stages_json ?? null),
|
|
1256
|
+
path_globs: parseStringArray(decisionValidation.values.path_globs_json ?? null),
|
|
1257
|
+
component_tags: parseStringArray(decisionValidation.values.component_tags_json ?? null),
|
|
1258
|
+
...metadata.values,
|
|
862
1259
|
},
|
|
863
1260
|
};
|
|
864
1261
|
}
|
|
@@ -870,15 +1267,17 @@ export const validateProjectMemorySuggestionPayload = (body) => {
|
|
|
870
1267
|
stages: input.stages,
|
|
871
1268
|
priority: input.priority,
|
|
872
1269
|
injection_mode: input.injection_mode,
|
|
1270
|
+
path_globs: input.path_globs,
|
|
1271
|
+
component_tags: input.component_tags,
|
|
873
1272
|
});
|
|
874
1273
|
if ('error' in conventionValidation) {
|
|
875
|
-
return
|
|
1274
|
+
return conventionValidation;
|
|
876
1275
|
}
|
|
877
1276
|
const scope = conventionValidation.values.scope ?? null;
|
|
878
1277
|
const stages = parseStringArray(conventionValidation.values.stages_json ?? null)
|
|
879
1278
|
.filter((entry) => PROJECT_MEMORY_STAGES.includes(entry));
|
|
880
1279
|
if (!scope && stages.length === 0) {
|
|
881
|
-
return { error: 'Convention suggestions require a scope or at least one stage' };
|
|
1280
|
+
return { error: 'Convention suggestions require a scope or at least one stage', code: 'invalid_field', field: 'scope' };
|
|
882
1281
|
}
|
|
883
1282
|
return {
|
|
884
1283
|
values: {
|
|
@@ -890,6 +1289,9 @@ export const validateProjectMemorySuggestionPayload = (body) => {
|
|
|
890
1289
|
stages,
|
|
891
1290
|
priority: conventionValidation.values.priority ?? 'normal',
|
|
892
1291
|
injection_mode: conventionValidation.values.injection_mode ?? 'relevant',
|
|
1292
|
+
path_globs: parseStringArray(conventionValidation.values.path_globs_json ?? null),
|
|
1293
|
+
component_tags: parseStringArray(conventionValidation.values.component_tags_json ?? null),
|
|
1294
|
+
...metadata.values,
|
|
893
1295
|
},
|
|
894
1296
|
};
|
|
895
1297
|
};
|
|
@@ -1016,104 +1418,195 @@ export const getProjectDecisionById = (projectId, decisionId) => {
|
|
|
1016
1418
|
const row = db.prepare('SELECT * FROM project_decisions WHERE id = ? AND project_id = ?').get(decisionId, projectId);
|
|
1017
1419
|
return row ? serializeDecision(row) : null;
|
|
1018
1420
|
};
|
|
1019
|
-
export const createProjectConvention = (projectId, values) => {
|
|
1421
|
+
export const createProjectConvention = (projectId, values, origin = createManualProjectMemoryOrigin()) => {
|
|
1020
1422
|
const id = randomUUID();
|
|
1021
|
-
db.
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1423
|
+
const transaction = db.transaction(() => {
|
|
1424
|
+
db.prepare(`
|
|
1425
|
+
INSERT INTO project_conventions (
|
|
1426
|
+
id, project_id, title, scope, instruction, rationale, stages_json, path_globs_json, component_tags_json, priority,
|
|
1427
|
+
injection_mode, status, source_suggestion_id, origin_json
|
|
1428
|
+
)
|
|
1429
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)
|
|
1430
|
+
`).run(id, projectId, values.title, values.scope, values.instruction, values.rationale, values.stages_json, values.path_globs_json, values.component_tags_json, values.priority, values.injection_mode, origin.suggestion_id, JSON.stringify(origin));
|
|
1431
|
+
recordProjectMemoryEvent({ projectId, memoryKind: 'convention', memoryId: id, eventType: 'created' });
|
|
1432
|
+
return getProjectConventionById(projectId, id);
|
|
1433
|
+
});
|
|
1434
|
+
return transaction();
|
|
1026
1435
|
};
|
|
1027
1436
|
export const updateProjectConvention = (projectId, conventionId, values) => {
|
|
1028
|
-
const
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1437
|
+
const transaction = db.transaction(() => {
|
|
1438
|
+
const current = getProjectConventionById(projectId, conventionId);
|
|
1439
|
+
if (!current) {
|
|
1440
|
+
return null;
|
|
1441
|
+
}
|
|
1442
|
+
db.prepare(`
|
|
1443
|
+
UPDATE project_conventions
|
|
1444
|
+
SET title = ?,
|
|
1445
|
+
scope = ?,
|
|
1446
|
+
instruction = ?,
|
|
1447
|
+
rationale = ?,
|
|
1448
|
+
stages_json = ?,
|
|
1449
|
+
path_globs_json = ?,
|
|
1450
|
+
component_tags_json = ?,
|
|
1451
|
+
priority = ?,
|
|
1452
|
+
injection_mode = ?,
|
|
1453
|
+
updated_at = CURRENT_TIMESTAMP
|
|
1454
|
+
WHERE id = ?
|
|
1455
|
+
AND project_id = ?
|
|
1456
|
+
`).run(values.title ?? current.title, Object.hasOwn(values, 'scope') ? values.scope : current.scope, values.instruction ?? current.instruction, Object.hasOwn(values, 'rationale') ? values.rationale : current.rationale, Object.hasOwn(values, 'stages_json') ? values.stages_json : (current.stages.length > 0 ? JSON.stringify(current.stages) : null), Object.hasOwn(values, 'path_globs_json') ? values.path_globs_json : (current.path_globs.length > 0 ? JSON.stringify(current.path_globs) : null), Object.hasOwn(values, 'component_tags_json') ? values.component_tags_json : (current.component_tags.length > 0 ? JSON.stringify(current.component_tags) : null), values.priority ?? current.priority, values.injection_mode ?? current.injection_mode, conventionId, projectId);
|
|
1457
|
+
recordProjectMemoryEvent({ projectId, memoryKind: 'convention', memoryId: conventionId, eventType: 'updated' });
|
|
1458
|
+
return getProjectConventionById(projectId, conventionId);
|
|
1459
|
+
});
|
|
1460
|
+
return transaction();
|
|
1461
|
+
};
|
|
1462
|
+
const transitionProjectConvention = (projectId, conventionId, expectedStatus, nextStatus, confirmConflicts = false) => {
|
|
1463
|
+
const transaction = db.transaction(() => {
|
|
1464
|
+
const current = getProjectConventionById(projectId, conventionId);
|
|
1465
|
+
if (!current) {
|
|
1466
|
+
return { status: 404, error: 'Convention not found' };
|
|
1467
|
+
}
|
|
1468
|
+
if (current.status !== expectedStatus) {
|
|
1469
|
+
return { status: 409, error: 'Convention is not in a state that allows this transition', memory: current };
|
|
1470
|
+
}
|
|
1471
|
+
if (nextStatus === 'active' && !confirmConflicts) {
|
|
1472
|
+
const conflicts = detectProjectMemoryConflicts(listProjectDecisions(projectId, { includeArchived: true }), listProjectConventions(projectId, { includeArchived: true }).map(item => item.id === conventionId ? { ...item, status: 'active' } : item));
|
|
1473
|
+
if (conflicts.length > 0) {
|
|
1474
|
+
return { status: 409, error: 'Restoring this memory creates conflicts', memory: current, conflicts, requires_confirmation: true };
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
const update = db.prepare(`
|
|
1478
|
+
UPDATE project_conventions
|
|
1479
|
+
SET status = ?, updated_at = CURRENT_TIMESTAMP
|
|
1480
|
+
WHERE id = ?
|
|
1481
|
+
AND project_id = ?
|
|
1482
|
+
AND status = ?
|
|
1483
|
+
`).run(nextStatus, conventionId, projectId, expectedStatus);
|
|
1484
|
+
if (update.changes !== 1) {
|
|
1485
|
+
return {
|
|
1486
|
+
status: 409,
|
|
1487
|
+
error: 'Convention changed before the transition could be applied',
|
|
1488
|
+
memory: getProjectConventionById(projectId, conventionId) ?? undefined,
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
const updated = getProjectConventionById(projectId, conventionId);
|
|
1492
|
+
if (!updated) {
|
|
1493
|
+
throw new Error('Convention disappeared after lifecycle transition');
|
|
1494
|
+
}
|
|
1495
|
+
recordProjectMemoryEvent({ projectId, memoryKind: 'convention', memoryId: conventionId, eventType: nextStatus === 'active' ? 'restored' : 'archived' });
|
|
1496
|
+
return { status: 200, memory: updated };
|
|
1497
|
+
});
|
|
1498
|
+
return transaction();
|
|
1046
1499
|
};
|
|
1047
1500
|
export const archiveProjectConvention = (projectId, conventionId) => {
|
|
1048
|
-
|
|
1049
|
-
UPDATE project_conventions
|
|
1050
|
-
SET status = 'archived', updated_at = CURRENT_TIMESTAMP
|
|
1051
|
-
WHERE id = ?
|
|
1052
|
-
AND project_id = ?
|
|
1053
|
-
`).run(conventionId, projectId);
|
|
1054
|
-
return getProjectConventionById(projectId, conventionId);
|
|
1501
|
+
return transitionProjectConvention(projectId, conventionId, 'active', 'archived');
|
|
1055
1502
|
};
|
|
1056
|
-
export const restoreProjectConvention = (projectId, conventionId) => {
|
|
1057
|
-
|
|
1058
|
-
UPDATE project_conventions
|
|
1059
|
-
SET status = 'active', updated_at = CURRENT_TIMESTAMP
|
|
1060
|
-
WHERE id = ?
|
|
1061
|
-
AND project_id = ?
|
|
1062
|
-
`).run(conventionId, projectId);
|
|
1063
|
-
return getProjectConventionById(projectId, conventionId);
|
|
1503
|
+
export const restoreProjectConvention = (projectId, conventionId, confirmConflicts = false) => {
|
|
1504
|
+
return transitionProjectConvention(projectId, conventionId, 'archived', 'active', confirmConflicts);
|
|
1064
1505
|
};
|
|
1065
|
-
export const createProjectDecision = (projectId, values) => {
|
|
1506
|
+
export const createProjectDecision = (projectId, values, origin = createManualProjectMemoryOrigin()) => {
|
|
1066
1507
|
const id = randomUUID();
|
|
1067
|
-
db.
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1508
|
+
const transaction = db.transaction(() => {
|
|
1509
|
+
db.prepare(`
|
|
1510
|
+
INSERT INTO project_decisions (
|
|
1511
|
+
id, project_id, title, scope, decision, rationale, implications_json, stages_json, path_globs_json, component_tags_json, status,
|
|
1512
|
+
source_suggestion_id, origin_json
|
|
1513
|
+
)
|
|
1514
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1515
|
+
`).run(id, projectId, values.title, values.scope, values.decision, values.rationale, values.implications_json, values.stages_json, values.path_globs_json, values.component_tags_json, ACCEPTED_PROJECT_DECISION_STATUS, origin.suggestion_id, JSON.stringify(origin));
|
|
1516
|
+
recordProjectMemoryEvent({ projectId, memoryKind: 'decision', memoryId: id, eventType: 'created' });
|
|
1517
|
+
return getProjectDecisionById(projectId, id);
|
|
1518
|
+
});
|
|
1519
|
+
return transaction();
|
|
1072
1520
|
};
|
|
1073
1521
|
export const updateProjectDecision = (projectId, decisionId, values) => {
|
|
1074
|
-
const
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1522
|
+
const transaction = db.transaction(() => {
|
|
1523
|
+
const current = getProjectDecisionById(projectId, decisionId);
|
|
1524
|
+
if (!current) {
|
|
1525
|
+
return null;
|
|
1526
|
+
}
|
|
1527
|
+
db.prepare(`
|
|
1528
|
+
UPDATE project_decisions
|
|
1529
|
+
SET title = ?,
|
|
1530
|
+
scope = ?,
|
|
1531
|
+
decision = ?,
|
|
1532
|
+
rationale = ?,
|
|
1533
|
+
implications_json = ?,
|
|
1534
|
+
stages_json = ?,
|
|
1535
|
+
path_globs_json = ?,
|
|
1536
|
+
component_tags_json = ?,
|
|
1537
|
+
updated_at = CURRENT_TIMESTAMP
|
|
1538
|
+
WHERE id = ?
|
|
1539
|
+
AND project_id = ?
|
|
1540
|
+
`).run(values.title ?? current.title, Object.hasOwn(values, 'scope') ? values.scope : current.scope, values.decision ?? current.decision, Object.hasOwn(values, 'rationale') ? values.rationale : current.rationale, Object.hasOwn(values, 'implications_json') ? values.implications_json : (current.implications.length > 0 ? JSON.stringify(current.implications) : null), Object.hasOwn(values, 'stages_json') ? values.stages_json : (current.stages.length > 0 ? JSON.stringify(current.stages) : null), Object.hasOwn(values, 'path_globs_json') ? values.path_globs_json : (current.path_globs.length > 0 ? JSON.stringify(current.path_globs) : null), Object.hasOwn(values, 'component_tags_json') ? values.component_tags_json : (current.component_tags.length > 0 ? JSON.stringify(current.component_tags) : null), decisionId, projectId);
|
|
1541
|
+
recordProjectMemoryEvent({ projectId, memoryKind: 'decision', memoryId: decisionId, eventType: 'updated' });
|
|
1542
|
+
return getProjectDecisionById(projectId, decisionId);
|
|
1543
|
+
});
|
|
1544
|
+
return transaction();
|
|
1545
|
+
};
|
|
1546
|
+
const transitionProjectDecision = (projectId, decisionId, expectedStatuses, nextStatus, confirmConflicts = false) => {
|
|
1547
|
+
const transaction = db.transaction(() => {
|
|
1548
|
+
const current = getProjectDecisionById(projectId, decisionId);
|
|
1549
|
+
if (!current) {
|
|
1550
|
+
return { status: 404, error: 'Decision not found' };
|
|
1551
|
+
}
|
|
1552
|
+
if (!expectedStatuses.includes(current.status)) {
|
|
1553
|
+
return { status: 409, error: 'Decision is not in a state that allows this transition', memory: current };
|
|
1554
|
+
}
|
|
1555
|
+
if (nextStatus === ACCEPTED_PROJECT_DECISION_STATUS && !confirmConflicts) {
|
|
1556
|
+
const conflicts = detectProjectMemoryConflicts(listProjectDecisions(projectId, { includeArchived: true }).map(item => item.id === decisionId ? { ...item, status: ACCEPTED_PROJECT_DECISION_STATUS } : item), listProjectConventions(projectId, { includeArchived: true }));
|
|
1557
|
+
if (conflicts.length > 0) {
|
|
1558
|
+
return { status: 409, error: 'Restoring this memory creates conflicts', memory: current, conflicts, requires_confirmation: true };
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
const update = db.prepare(`
|
|
1562
|
+
UPDATE project_decisions
|
|
1563
|
+
SET status = ?, updated_at = CURRENT_TIMESTAMP
|
|
1564
|
+
WHERE id = ?
|
|
1565
|
+
AND project_id = ?
|
|
1566
|
+
AND status = ?
|
|
1567
|
+
`).run(nextStatus, decisionId, projectId, current.status);
|
|
1568
|
+
if (update.changes !== 1) {
|
|
1569
|
+
return {
|
|
1570
|
+
status: 409,
|
|
1571
|
+
error: 'Decision changed before the transition could be applied',
|
|
1572
|
+
memory: getProjectDecisionById(projectId, decisionId) ?? undefined,
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
const updated = getProjectDecisionById(projectId, decisionId);
|
|
1576
|
+
if (!updated) {
|
|
1577
|
+
throw new Error('Decision disappeared after lifecycle transition');
|
|
1578
|
+
}
|
|
1579
|
+
recordProjectMemoryEvent({ projectId, memoryKind: 'decision', memoryId: decisionId, eventType: nextStatus === ACCEPTED_PROJECT_DECISION_STATUS ? 'restored' : 'archived' });
|
|
1580
|
+
return { status: 200, memory: updated };
|
|
1581
|
+
});
|
|
1582
|
+
return transaction();
|
|
1090
1583
|
};
|
|
1091
1584
|
export const archiveProjectDecision = (projectId, decisionId) => {
|
|
1092
|
-
|
|
1093
|
-
UPDATE project_decisions
|
|
1094
|
-
SET status = 'archived', updated_at = CURRENT_TIMESTAMP
|
|
1095
|
-
WHERE id = ?
|
|
1096
|
-
AND project_id = ?
|
|
1097
|
-
`).run(decisionId, projectId);
|
|
1098
|
-
return getProjectDecisionById(projectId, decisionId);
|
|
1585
|
+
return transitionProjectDecision(projectId, decisionId, [ACCEPTED_PROJECT_DECISION_STATUS], 'archived');
|
|
1099
1586
|
};
|
|
1100
|
-
export const supersedeProjectDecision = (projectId, decisionId) => {
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1587
|
+
export const supersedeProjectDecision = (projectId, decisionId, replacementDecisionId) => {
|
|
1588
|
+
const current = getProjectDecisionById(projectId, decisionId);
|
|
1589
|
+
if (!current) {
|
|
1590
|
+
return { status: 404, error: 'Decision not found' };
|
|
1591
|
+
}
|
|
1592
|
+
if (current.status !== ACCEPTED_PROJECT_DECISION_STATUS) {
|
|
1593
|
+
return { status: 409, error: 'Decision is not in a state that allows this transition', memory: current };
|
|
1594
|
+
}
|
|
1595
|
+
const update = db.prepare(`
|
|
1596
|
+
UPDATE project_decisions
|
|
1597
|
+
SET status = 'superseded', superseded_by_decision_id = ?, updated_at = CURRENT_TIMESTAMP
|
|
1598
|
+
WHERE id = ? AND project_id = ? AND status = ?
|
|
1599
|
+
AND ? <> id
|
|
1600
|
+
AND EXISTS (SELECT 1 FROM project_decisions replacement WHERE replacement.id = ? AND replacement.project_id = ? AND replacement.status = 'accepted')
|
|
1601
|
+
`).run(replacementDecisionId, decisionId, projectId, ACCEPTED_PROJECT_DECISION_STATUS, replacementDecisionId, replacementDecisionId, projectId);
|
|
1602
|
+
if (update.changes !== 1) {
|
|
1603
|
+
return { status: 400, error: 'replacement_decision_id must name another accepted decision in this project', memory: getProjectDecisionById(projectId, decisionId) ?? undefined };
|
|
1604
|
+
}
|
|
1605
|
+
recordProjectMemoryEvent({ projectId, memoryKind: 'decision', memoryId: decisionId, eventType: 'superseded', summary: { replacement_decision_id: replacementDecisionId } });
|
|
1606
|
+
return { status: 200, memory: getProjectDecisionById(projectId, decisionId) ?? undefined };
|
|
1108
1607
|
};
|
|
1109
|
-
export const restoreProjectDecision = (projectId, decisionId) => {
|
|
1110
|
-
|
|
1111
|
-
UPDATE project_decisions
|
|
1112
|
-
SET status = ?, updated_at = CURRENT_TIMESTAMP
|
|
1113
|
-
WHERE id = ?
|
|
1114
|
-
AND project_id = ?
|
|
1115
|
-
`).run(ACCEPTED_PROJECT_DECISION_STATUS, decisionId, projectId);
|
|
1116
|
-
return getProjectDecisionById(projectId, decisionId);
|
|
1608
|
+
export const restoreProjectDecision = (projectId, decisionId, confirmConflicts = false) => {
|
|
1609
|
+
return transitionProjectDecision(projectId, decisionId, ['archived', 'superseded'], ACCEPTED_PROJECT_DECISION_STATUS, confirmConflicts);
|
|
1117
1610
|
};
|
|
1118
1611
|
const getProjectMemorySuggestionRowById = (projectId, suggestionId) => {
|
|
1119
1612
|
return db.prepare(`
|
|
@@ -1191,9 +1684,13 @@ export const listProjectMemories = (projectId, { states, kinds, sources, ticketI
|
|
|
1191
1684
|
instruction: convention.instruction,
|
|
1192
1685
|
rationale: convention.rationale,
|
|
1193
1686
|
stages_json: convention.stages.length > 0 ? JSON.stringify(convention.stages) : null,
|
|
1687
|
+
path_globs_json: convention.path_globs.length > 0 ? JSON.stringify(convention.path_globs) : null,
|
|
1688
|
+
component_tags_json: convention.component_tags.length > 0 ? JSON.stringify(convention.component_tags) : null,
|
|
1194
1689
|
priority: convention.priority,
|
|
1195
1690
|
injection_mode: convention.injection_mode,
|
|
1196
1691
|
status: convention.status,
|
|
1692
|
+
source_suggestion_id: convention.origin?.suggestion_id ?? null,
|
|
1693
|
+
origin_json: convention.origin ? JSON.stringify(convention.origin) : null,
|
|
1197
1694
|
created_at: convention.created_at,
|
|
1198
1695
|
updated_at: convention.updated_at,
|
|
1199
1696
|
})),
|
|
@@ -1205,7 +1702,13 @@ export const listProjectMemories = (projectId, { states, kinds, sources, ticketI
|
|
|
1205
1702
|
decision: decision.decision,
|
|
1206
1703
|
rationale: decision.rationale,
|
|
1207
1704
|
implications_json: decision.implications.length > 0 ? JSON.stringify(decision.implications) : null,
|
|
1705
|
+
stages_json: decision.stages.length > 0 ? JSON.stringify(decision.stages) : null,
|
|
1706
|
+
path_globs_json: decision.path_globs.length > 0 ? JSON.stringify(decision.path_globs) : null,
|
|
1707
|
+
component_tags_json: decision.component_tags.length > 0 ? JSON.stringify(decision.component_tags) : null,
|
|
1208
1708
|
status: decision.status,
|
|
1709
|
+
superseded_by_decision_id: decision.superseded_by_decision_id,
|
|
1710
|
+
source_suggestion_id: decision.origin?.suggestion_id ?? null,
|
|
1711
|
+
origin_json: decision.origin ? JSON.stringify(decision.origin) : null,
|
|
1209
1712
|
created_at: decision.created_at,
|
|
1210
1713
|
updated_at: decision.updated_at,
|
|
1211
1714
|
})),
|
|
@@ -1254,6 +1757,10 @@ export const getProjectMemoryById = (projectId, memoryId) => {
|
|
|
1254
1757
|
return suggestion ? serializeProjectMemoryFromSuggestionValue(suggestion) : null;
|
|
1255
1758
|
};
|
|
1256
1759
|
const persistProjectMemorySuggestionsForSource = (projectId, source, sourceStage, suggestions) => {
|
|
1760
|
+
const project = db.prepare('SELECT memory_enabled FROM projects WHERE id = ?').get(projectId);
|
|
1761
|
+
if (!project?.memory_enabled) {
|
|
1762
|
+
return [];
|
|
1763
|
+
}
|
|
1257
1764
|
if (!source.ticketId && !source.chatSessionId && !source.specSessionId) {
|
|
1258
1765
|
return [];
|
|
1259
1766
|
}
|
|
@@ -1273,8 +1780,38 @@ const persistProjectMemorySuggestionsForSource = (projectId, source, sourceStage
|
|
|
1273
1780
|
: new Set();
|
|
1274
1781
|
const createdIds = [];
|
|
1275
1782
|
for (const suggestion of suggestions) {
|
|
1783
|
+
const recommendedTarget = suggestion.recommendation?.target_memory_id
|
|
1784
|
+
? suggestion.kind === 'decision'
|
|
1785
|
+
? getProjectDecisionById(projectId, suggestion.recommendation.target_memory_id)
|
|
1786
|
+
: getProjectConventionById(projectId, suggestion.recommendation.target_memory_id)
|
|
1787
|
+
: null;
|
|
1788
|
+
const recommendation = suggestion.recommendation
|
|
1789
|
+
? {
|
|
1790
|
+
...suggestion.recommendation,
|
|
1791
|
+
target_memory_id: recommendedTarget
|
|
1792
|
+
&& ((suggestion.kind === 'decision' && recommendedTarget.status === ACCEPTED_PROJECT_DECISION_STATUS)
|
|
1793
|
+
|| (suggestion.kind === 'convention' && recommendedTarget.status === 'active'))
|
|
1794
|
+
? recommendedTarget.id
|
|
1795
|
+
: null,
|
|
1796
|
+
}
|
|
1797
|
+
: null;
|
|
1798
|
+
const resolvedSuggestion = { ...suggestion, recommendation };
|
|
1799
|
+
const fingerprint = getProjectMemorySuggestionFingerprint(projectId, resolvedSuggestion);
|
|
1800
|
+
const suppressed = db.prepare(`
|
|
1801
|
+
SELECT 1
|
|
1802
|
+
FROM project_memory_suggestions
|
|
1803
|
+
WHERE project_id = ? AND dedupe_fingerprint = ?
|
|
1804
|
+
AND (
|
|
1805
|
+
status IN ('accepted', 'applied_to_existing')
|
|
1806
|
+
OR (status = 'dismissed' AND updated_at >= datetime('now', ?))
|
|
1807
|
+
)
|
|
1808
|
+
LIMIT 1
|
|
1809
|
+
`).get(projectId, fingerprint, `-${PROJECT_MEMORY_SUGGESTION_DISMISSAL_COOLDOWN_DAYS} days`);
|
|
1810
|
+
if (suppressed) {
|
|
1811
|
+
continue;
|
|
1812
|
+
}
|
|
1276
1813
|
const duplicate = classifyProjectMemorySuggestionDuplicate({
|
|
1277
|
-
suggestion,
|
|
1814
|
+
suggestion: resolvedSuggestion,
|
|
1278
1815
|
activeConventions,
|
|
1279
1816
|
activeDecisions,
|
|
1280
1817
|
pendingSuggestions: pendingSuggestionCandidates,
|
|
@@ -1311,13 +1848,14 @@ const persistProjectMemorySuggestionsForSource = (projectId, source, sourceStage
|
|
|
1311
1848
|
duplicate_target_id,
|
|
1312
1849
|
status,
|
|
1313
1850
|
resolved_memory_id
|
|
1851
|
+
, dedupe_fingerprint
|
|
1314
1852
|
)
|
|
1315
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL)
|
|
1316
|
-
`).run(id, projectId, source.ticketId ?? null, source.chatSessionId ?? null, source.specSessionId ?? null, sourceStage,
|
|
1317
|
-
? { implications:
|
|
1318
|
-
: { stages:
|
|
1853
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL, ?)
|
|
1854
|
+
`).run(id, projectId, source.ticketId ?? null, source.chatSessionId ?? null, source.specSessionId ?? null, sourceStage, resolvedSuggestion.kind, resolvedSuggestion.title, resolvedSuggestion.scope, getProjectMemorySuggestionContent(resolvedSuggestion), resolvedSuggestion.rationale, JSON.stringify(resolvedSuggestion.kind === 'decision'
|
|
1855
|
+
? { implications: resolvedSuggestion.implications, stages: resolvedSuggestion.stages, path_globs: resolvedSuggestion.path_globs, component_tags: resolvedSuggestion.component_tags, evidence: resolvedSuggestion.evidence, recommendation: resolvedSuggestion.recommendation }
|
|
1856
|
+
: { stages: resolvedSuggestion.stages, path_globs: resolvedSuggestion.path_globs, component_tags: resolvedSuggestion.component_tags, priority: resolvedSuggestion.priority, injection_mode: resolvedSuggestion.injection_mode, evidence: resolvedSuggestion.evidence, recommendation: resolvedSuggestion.recommendation }), duplicate?.action === 'mark_near_duplicate' ? 'near_duplicate' : 'none', duplicate?.action === 'mark_near_duplicate' ? duplicate.targetMemoryId : null, fingerprint);
|
|
1319
1857
|
createdIds.push(id);
|
|
1320
|
-
pendingSuggestionCandidates.push({ id, ...
|
|
1858
|
+
pendingSuggestionCandidates.push({ id, ...resolvedSuggestion });
|
|
1321
1859
|
}
|
|
1322
1860
|
return createdIds
|
|
1323
1861
|
.map(id => getProjectMemorySuggestionById(projectId, id))
|
|
@@ -1332,20 +1870,28 @@ export const persistChatSessionProjectMemorySuggestions = (projectId, chatSessio
|
|
|
1332
1870
|
export const persistSpecSessionProjectMemorySuggestions = (projectId, specSessionId, suggestions) => {
|
|
1333
1871
|
return persistProjectMemorySuggestionsForSource(projectId, { specSessionId }, 'plan', suggestions);
|
|
1334
1872
|
};
|
|
1335
|
-
const
|
|
1336
|
-
db.prepare(`
|
|
1873
|
+
const resolvePendingProjectMemorySuggestion = (projectId, suggestionId, status, resolvedMemoryId) => {
|
|
1874
|
+
const update = db.prepare(`
|
|
1337
1875
|
UPDATE project_memory_suggestions
|
|
1338
1876
|
SET status = ?,
|
|
1339
1877
|
resolved_memory_id = ?,
|
|
1340
1878
|
updated_at = CURRENT_TIMESTAMP
|
|
1341
1879
|
WHERE id = ?
|
|
1342
1880
|
AND project_id = ?
|
|
1881
|
+
AND status = 'pending'
|
|
1343
1882
|
`).run(status, resolvedMemoryId, suggestionId, projectId);
|
|
1344
|
-
return
|
|
1883
|
+
return update.changes === 1;
|
|
1884
|
+
};
|
|
1885
|
+
const recordProjectMemoryEvent = ({ projectId, memoryKind, memoryId, eventType, sourceSuggestionId = null, summary = null, }) => {
|
|
1886
|
+
db.prepare(`
|
|
1887
|
+
INSERT INTO project_memory_events (
|
|
1888
|
+
id, project_id, memory_kind, memory_id, event_type, source_suggestion_id, summary_json
|
|
1889
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1890
|
+
`).run(randomUUID(), projectId, memoryKind, memoryId, eventType, sourceSuggestionId, summary == null ? null : JSON.stringify(summary));
|
|
1345
1891
|
};
|
|
1346
1892
|
const mergeProjectMemorySuggestionDecisionValues = (suggestion, overrides) => {
|
|
1347
1893
|
if (suggestion.kind !== 'decision') {
|
|
1348
|
-
return { error: 'Suggestion is not a decision' };
|
|
1894
|
+
return { error: 'Suggestion is not a decision', code: 'invalid_field', field: 'kind' };
|
|
1349
1895
|
}
|
|
1350
1896
|
const merged = {
|
|
1351
1897
|
title: overrides.title ?? suggestion.title,
|
|
@@ -1355,12 +1901,15 @@ const mergeProjectMemorySuggestionDecisionValues = (suggestion, overrides) => {
|
|
|
1355
1901
|
implications: Object.hasOwn(overrides, 'implications_json')
|
|
1356
1902
|
? parseStringArray(overrides.implications_json)
|
|
1357
1903
|
: suggestion.implications,
|
|
1904
|
+
stages: Object.hasOwn(overrides, 'stages_json') ? parseStringArray(overrides.stages_json) : suggestion.stages,
|
|
1905
|
+
path_globs: Object.hasOwn(overrides, 'path_globs_json') ? parseStringArray(overrides.path_globs_json) : suggestion.path_globs,
|
|
1906
|
+
component_tags: Object.hasOwn(overrides, 'component_tags_json') ? parseStringArray(overrides.component_tags_json) : suggestion.component_tags,
|
|
1358
1907
|
};
|
|
1359
1908
|
return validateProjectDecisionPayload(merged);
|
|
1360
1909
|
};
|
|
1361
1910
|
const mergeProjectMemorySuggestionConventionValues = (suggestion, overrides) => {
|
|
1362
1911
|
if (suggestion.kind !== 'convention') {
|
|
1363
|
-
return { error: 'Suggestion is not a convention' };
|
|
1912
|
+
return { error: 'Suggestion is not a convention', code: 'invalid_field', field: 'kind' };
|
|
1364
1913
|
}
|
|
1365
1914
|
const merged = {
|
|
1366
1915
|
title: overrides.title ?? suggestion.title,
|
|
@@ -1372,178 +1921,426 @@ const mergeProjectMemorySuggestionConventionValues = (suggestion, overrides) =>
|
|
|
1372
1921
|
: suggestion.stages,
|
|
1373
1922
|
priority: overrides.priority ?? suggestion.priority,
|
|
1374
1923
|
injection_mode: overrides.injection_mode ?? suggestion.injection_mode,
|
|
1924
|
+
path_globs: Object.hasOwn(overrides, 'path_globs_json') ? parseStringArray(overrides.path_globs_json) : suggestion.path_globs,
|
|
1925
|
+
component_tags: Object.hasOwn(overrides, 'component_tags_json') ? parseStringArray(overrides.component_tags_json) : suggestion.component_tags,
|
|
1375
1926
|
};
|
|
1376
1927
|
return validateProjectConventionPayload(merged);
|
|
1377
1928
|
};
|
|
1378
|
-
export const dismissProjectMemorySuggestion = (projectId, suggestionId) => {
|
|
1379
|
-
|
|
1380
|
-
};
|
|
1381
|
-
export const acceptProjectMemorySuggestion = (projectId, suggestionId, overrides = {}) => {
|
|
1382
|
-
const suggestion = getProjectMemorySuggestionById(projectId, suggestionId);
|
|
1383
|
-
if (!suggestion) {
|
|
1384
|
-
return { status: 404, error: 'Suggestion not found' };
|
|
1929
|
+
export const dismissProjectMemorySuggestion = (projectId, suggestionId, reason = null) => {
|
|
1930
|
+
if (reason !== null && !PROJECT_MEMORY_SUGGESTION_DISMISSAL_REASONS.includes(reason)) {
|
|
1931
|
+
return { status: 400, error: 'reason must be a supported dismissal reason' };
|
|
1385
1932
|
}
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1933
|
+
const transaction = db.transaction(() => {
|
|
1934
|
+
const suggestion = getProjectMemorySuggestionById(projectId, suggestionId);
|
|
1935
|
+
if (!suggestion) {
|
|
1936
|
+
return { status: 404, error: 'Suggestion not found' };
|
|
1937
|
+
}
|
|
1938
|
+
if (suggestion.status !== 'pending') {
|
|
1939
|
+
return { status: 409, error: 'Suggestion is no longer pending', suggestion };
|
|
1940
|
+
}
|
|
1941
|
+
if (!resolvePendingProjectMemorySuggestion(projectId, suggestionId, 'dismissed', null)) {
|
|
1942
|
+
return {
|
|
1943
|
+
status: 409,
|
|
1944
|
+
error: 'Suggestion changed before the dismissal could be applied',
|
|
1945
|
+
suggestion: getProjectMemorySuggestionById(projectId, suggestionId) ?? suggestion,
|
|
1946
|
+
};
|
|
1947
|
+
}
|
|
1948
|
+
db.prepare('UPDATE project_memory_suggestions SET dismissal_reason = ? WHERE id = ? AND project_id = ?').run(reason, suggestionId, projectId);
|
|
1949
|
+
const dismissed = getProjectMemorySuggestionById(projectId, suggestionId);
|
|
1950
|
+
if (!dismissed) {
|
|
1951
|
+
throw new Error('Suggestion disappeared after dismissal');
|
|
1952
|
+
}
|
|
1953
|
+
return { suggestion: dismissed };
|
|
1954
|
+
});
|
|
1955
|
+
return transaction();
|
|
1389
1956
|
};
|
|
1390
|
-
export const
|
|
1957
|
+
export const acceptProjectMemorySuggestion = (projectId, suggestionId, overrides = {}, confirmConflicts = false) => {
|
|
1391
1958
|
const suggestion = getProjectMemorySuggestionById(projectId, suggestionId);
|
|
1392
1959
|
if (!suggestion) {
|
|
1393
1960
|
return { status: 404, error: 'Suggestion not found' };
|
|
1394
1961
|
}
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
suggestion
|
|
1404
|
-
|
|
1962
|
+
return suggestion.kind === 'decision'
|
|
1963
|
+
? acceptProjectMemorySuggestionAsDecision(projectId, suggestionId, overrides, confirmConflicts)
|
|
1964
|
+
: acceptProjectMemorySuggestionAsConvention(projectId, suggestionId, overrides, confirmConflicts);
|
|
1965
|
+
};
|
|
1966
|
+
export const acceptProjectMemorySuggestionAsDecision = (projectId, suggestionId, overrides = {}, confirmConflicts = false) => {
|
|
1967
|
+
let suggestionBeforeMutation = null;
|
|
1968
|
+
const transaction = db.transaction(() => {
|
|
1969
|
+
const suggestion = getProjectMemorySuggestionById(projectId, suggestionId);
|
|
1970
|
+
suggestionBeforeMutation = suggestion;
|
|
1971
|
+
if (!suggestion) {
|
|
1972
|
+
return { status: 404, error: 'Suggestion not found' };
|
|
1973
|
+
}
|
|
1974
|
+
if (suggestion.status !== 'pending') {
|
|
1975
|
+
return { status: 409, error: 'Suggestion is no longer pending', suggestion };
|
|
1976
|
+
}
|
|
1977
|
+
const merged = mergeProjectMemorySuggestionDecisionValues(suggestion, overrides);
|
|
1978
|
+
if ('error' in merged) {
|
|
1979
|
+
return { status: merged.code === 'field_too_large' ? 413 : 400, ...merged, suggestion };
|
|
1980
|
+
}
|
|
1981
|
+
const origin = createProjectMemorySuggestionOrigin(projectId, suggestion);
|
|
1982
|
+
if (!origin) {
|
|
1983
|
+
return { status: 409, error: 'Suggestion source is no longer available for this project', suggestion };
|
|
1984
|
+
}
|
|
1985
|
+
const candidate = {
|
|
1986
|
+
id: suggestionId,
|
|
1987
|
+
project_id: projectId,
|
|
1988
|
+
title: merged.values.title ?? '', scope: merged.values.scope ?? null, decision: merged.values.decision ?? '', rationale: merged.values.rationale ?? null,
|
|
1989
|
+
implications: parseStringArray(merged.values.implications_json ?? null), stages: parseStringArray(merged.values.stages_json ?? null),
|
|
1990
|
+
path_globs: parseStringArray(merged.values.path_globs_json ?? null), component_tags: parseStringArray(merged.values.component_tags_json ?? null),
|
|
1991
|
+
status: ACCEPTED_PROJECT_DECISION_STATUS, superseded_by_decision_id: null, created_at: suggestion.created_at, updated_at: suggestion.updated_at,
|
|
1992
|
+
};
|
|
1993
|
+
const conflicts = detectProjectMemoryConflicts([...listProjectDecisions(projectId), candidate], listProjectConventions(projectId));
|
|
1994
|
+
if (conflicts.length > 0 && !confirmConflicts) {
|
|
1995
|
+
return { status: 409, error: 'Accepting this suggestion creates conflicts', suggestion, conflicts, requires_confirmation: true };
|
|
1996
|
+
}
|
|
1997
|
+
const duplicate = classifyProjectMemorySuggestionDuplicate({
|
|
1998
|
+
suggestion: {
|
|
1999
|
+
kind: 'decision',
|
|
2000
|
+
title: merged.values.title ?? '',
|
|
2001
|
+
scope: merged.values.scope ?? null,
|
|
2002
|
+
decision: merged.values.decision ?? '',
|
|
2003
|
+
rationale: merged.values.rationale ?? null,
|
|
2004
|
+
implications: parseStringArray(merged.values.implications_json ?? null),
|
|
2005
|
+
stages: parseStringArray(merged.values.stages_json ?? null),
|
|
2006
|
+
path_globs: parseStringArray(merged.values.path_globs_json ?? null),
|
|
2007
|
+
component_tags: parseStringArray(merged.values.component_tags_json ?? null),
|
|
2008
|
+
evidence: suggestion.evidence,
|
|
2009
|
+
recommendation: suggestion.recommendation,
|
|
2010
|
+
},
|
|
2011
|
+
activeConventions: [],
|
|
2012
|
+
activeDecisions: listProjectDecisions(projectId),
|
|
2013
|
+
pendingSuggestions: listProjectMemorySuggestionRows(projectId, { status: 'pending' })
|
|
2014
|
+
.filter(row => row.id !== suggestionId)
|
|
2015
|
+
.map(toProjectMemorySuggestionCandidate)
|
|
2016
|
+
.filter((candidate) => candidate !== null),
|
|
2017
|
+
});
|
|
2018
|
+
if (duplicate?.action === 'suppress' && duplicate.target === 'decision') {
|
|
2019
|
+
return { status: 409, error: 'A matching decision already exists for this project', suggestion };
|
|
2020
|
+
}
|
|
2021
|
+
const createdDecision = createProjectDecision(projectId, {
|
|
1405
2022
|
title: merged.values.title ?? '',
|
|
1406
2023
|
scope: merged.values.scope ?? null,
|
|
1407
2024
|
decision: merged.values.decision ?? '',
|
|
1408
2025
|
rationale: merged.values.rationale ?? null,
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
rationale: merged.values.rationale ?? null,
|
|
1426
|
-
implications_json: merged.values.implications_json ?? null,
|
|
2026
|
+
implications_json: merged.values.implications_json ?? null,
|
|
2027
|
+
stages_json: merged.values.stages_json ?? null,
|
|
2028
|
+
path_globs_json: merged.values.path_globs_json ?? null,
|
|
2029
|
+
component_tags_json: merged.values.component_tags_json ?? null,
|
|
2030
|
+
}, origin);
|
|
2031
|
+
if (!createdDecision) {
|
|
2032
|
+
throw new Error('Unable to create the suggested decision');
|
|
2033
|
+
}
|
|
2034
|
+
if (!resolvePendingProjectMemorySuggestion(projectId, suggestionId, 'accepted', createdDecision.id)) {
|
|
2035
|
+
throw new ProjectMemoryLifecycleConflictError('Suggestion changed before the acceptance could be applied');
|
|
2036
|
+
}
|
|
2037
|
+
const acceptedSuggestion = getProjectMemorySuggestionById(projectId, suggestionId);
|
|
2038
|
+
if (!acceptedSuggestion) {
|
|
2039
|
+
throw new Error('Suggestion disappeared after acceptance');
|
|
2040
|
+
}
|
|
2041
|
+
return { suggestion: acceptedSuggestion, decision: createdDecision };
|
|
1427
2042
|
});
|
|
1428
|
-
|
|
1429
|
-
return
|
|
2043
|
+
try {
|
|
2044
|
+
return transaction();
|
|
1430
2045
|
}
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
2046
|
+
catch (error) {
|
|
2047
|
+
if (error instanceof ProjectMemoryLifecycleConflictError) {
|
|
2048
|
+
return {
|
|
2049
|
+
status: 409,
|
|
2050
|
+
error: error.message,
|
|
2051
|
+
suggestion: getProjectMemorySuggestionById(projectId, suggestionId) ?? suggestionBeforeMutation,
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
return {
|
|
2055
|
+
status: 500,
|
|
2056
|
+
error: 'Unable to accept the suggested decision',
|
|
2057
|
+
suggestion: getProjectMemorySuggestionById(projectId, suggestionId) ?? suggestionBeforeMutation,
|
|
2058
|
+
};
|
|
1434
2059
|
}
|
|
1435
|
-
return {
|
|
1436
|
-
suggestion: updatedSuggestion,
|
|
1437
|
-
decision: createdDecision,
|
|
1438
|
-
};
|
|
1439
2060
|
};
|
|
1440
|
-
export const acceptProjectMemorySuggestionAsConvention = (projectId, suggestionId, overrides = {}) => {
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
2061
|
+
export const acceptProjectMemorySuggestionAsConvention = (projectId, suggestionId, overrides = {}, confirmConflicts = false) => {
|
|
2062
|
+
let suggestionBeforeMutation = null;
|
|
2063
|
+
const transaction = db.transaction(() => {
|
|
2064
|
+
const suggestion = getProjectMemorySuggestionById(projectId, suggestionId);
|
|
2065
|
+
suggestionBeforeMutation = suggestion;
|
|
2066
|
+
if (!suggestion) {
|
|
2067
|
+
return { status: 404, error: 'Suggestion not found' };
|
|
2068
|
+
}
|
|
2069
|
+
if (suggestion.status !== 'pending') {
|
|
2070
|
+
return { status: 409, error: 'Suggestion is no longer pending', suggestion };
|
|
2071
|
+
}
|
|
2072
|
+
const merged = mergeProjectMemorySuggestionConventionValues(suggestion, overrides);
|
|
2073
|
+
if ('error' in merged) {
|
|
2074
|
+
return { status: merged.code === 'field_too_large' ? 413 : 400, ...merged, suggestion };
|
|
2075
|
+
}
|
|
2076
|
+
const origin = createProjectMemorySuggestionOrigin(projectId, suggestion);
|
|
2077
|
+
if (!origin) {
|
|
2078
|
+
return { status: 409, error: 'Suggestion source is no longer available for this project', suggestion };
|
|
2079
|
+
}
|
|
2080
|
+
const candidate = {
|
|
2081
|
+
id: suggestionId, project_id: projectId, title: merged.values.title ?? '', scope: merged.values.scope ?? null,
|
|
2082
|
+
instruction: merged.values.instruction ?? '', rationale: merged.values.rationale ?? null,
|
|
2083
|
+
stages: parseStringArray(merged.values.stages_json ?? null), path_globs: parseStringArray(merged.values.path_globs_json ?? null),
|
|
2084
|
+
component_tags: parseStringArray(merged.values.component_tags_json ?? null), priority: merged.values.priority ?? 'normal',
|
|
2085
|
+
injection_mode: merged.values.injection_mode ?? 'relevant', status: 'active', created_at: suggestion.created_at, updated_at: suggestion.updated_at,
|
|
2086
|
+
};
|
|
2087
|
+
const conflicts = detectProjectMemoryConflicts(listProjectDecisions(projectId), [...listProjectConventions(projectId), candidate]);
|
|
2088
|
+
if (conflicts.length > 0 && !confirmConflicts) {
|
|
2089
|
+
return { status: 409, error: 'Accepting this suggestion creates conflicts', suggestion, conflicts, requires_confirmation: true };
|
|
2090
|
+
}
|
|
2091
|
+
const duplicate = classifyProjectMemorySuggestionDuplicate({
|
|
2092
|
+
suggestion: {
|
|
2093
|
+
kind: 'convention',
|
|
2094
|
+
title: merged.values.title ?? '',
|
|
2095
|
+
scope: merged.values.scope ?? null,
|
|
2096
|
+
instruction: merged.values.instruction ?? '',
|
|
2097
|
+
rationale: merged.values.rationale ?? null,
|
|
2098
|
+
stages: parseStringArray(merged.values.stages_json ?? null)
|
|
2099
|
+
.filter((entry) => PROJECT_MEMORY_STAGES.includes(entry)),
|
|
2100
|
+
priority: merged.values.priority ?? 'normal',
|
|
2101
|
+
injection_mode: merged.values.injection_mode ?? 'relevant',
|
|
2102
|
+
path_globs: parseStringArray(merged.values.path_globs_json ?? null),
|
|
2103
|
+
component_tags: parseStringArray(merged.values.component_tags_json ?? null),
|
|
2104
|
+
evidence: suggestion.evidence,
|
|
2105
|
+
recommendation: suggestion.recommendation,
|
|
2106
|
+
},
|
|
2107
|
+
activeConventions: listProjectConventions(projectId),
|
|
2108
|
+
activeDecisions: [],
|
|
2109
|
+
pendingSuggestions: listProjectMemorySuggestionRows(projectId, { status: 'pending' })
|
|
2110
|
+
.filter(row => row.id !== suggestionId)
|
|
2111
|
+
.map(toProjectMemorySuggestionCandidate)
|
|
2112
|
+
.filter((candidate) => candidate !== null),
|
|
2113
|
+
});
|
|
2114
|
+
if (duplicate?.action === 'suppress' && duplicate.target === 'convention') {
|
|
2115
|
+
return { status: 409, error: 'A matching convention already exists for this project', suggestion };
|
|
2116
|
+
}
|
|
2117
|
+
const createdConvention = createProjectConvention(projectId, {
|
|
1455
2118
|
title: merged.values.title ?? '',
|
|
1456
2119
|
scope: merged.values.scope ?? null,
|
|
1457
2120
|
instruction: merged.values.instruction ?? '',
|
|
1458
2121
|
rationale: merged.values.rationale ?? null,
|
|
1459
|
-
|
|
1460
|
-
|
|
2122
|
+
stages_json: merged.values.stages_json ?? null,
|
|
2123
|
+
path_globs_json: merged.values.path_globs_json ?? null,
|
|
2124
|
+
component_tags_json: merged.values.component_tags_json ?? null,
|
|
1461
2125
|
priority: merged.values.priority ?? 'normal',
|
|
1462
2126
|
injection_mode: merged.values.injection_mode ?? 'relevant',
|
|
1463
|
-
},
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
title: merged.values.title ?? '',
|
|
1476
|
-
scope: merged.values.scope ?? null,
|
|
1477
|
-
instruction: merged.values.instruction ?? '',
|
|
1478
|
-
rationale: merged.values.rationale ?? null,
|
|
1479
|
-
stages_json: merged.values.stages_json ?? null,
|
|
1480
|
-
priority: merged.values.priority ?? 'normal',
|
|
1481
|
-
injection_mode: merged.values.injection_mode ?? 'relevant',
|
|
2127
|
+
}, origin);
|
|
2128
|
+
if (!createdConvention) {
|
|
2129
|
+
throw new Error('Unable to create the suggested convention');
|
|
2130
|
+
}
|
|
2131
|
+
if (!resolvePendingProjectMemorySuggestion(projectId, suggestionId, 'accepted', createdConvention.id)) {
|
|
2132
|
+
throw new ProjectMemoryLifecycleConflictError('Suggestion changed before the acceptance could be applied');
|
|
2133
|
+
}
|
|
2134
|
+
const acceptedSuggestion = getProjectMemorySuggestionById(projectId, suggestionId);
|
|
2135
|
+
if (!acceptedSuggestion) {
|
|
2136
|
+
throw new Error('Suggestion disappeared after acceptance');
|
|
2137
|
+
}
|
|
2138
|
+
return { suggestion: acceptedSuggestion, convention: createdConvention };
|
|
1482
2139
|
});
|
|
1483
|
-
|
|
1484
|
-
return
|
|
2140
|
+
try {
|
|
2141
|
+
return transaction();
|
|
1485
2142
|
}
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
2143
|
+
catch (error) {
|
|
2144
|
+
if (error instanceof ProjectMemoryLifecycleConflictError) {
|
|
2145
|
+
return {
|
|
2146
|
+
status: 409,
|
|
2147
|
+
error: error.message,
|
|
2148
|
+
suggestion: getProjectMemorySuggestionById(projectId, suggestionId) ?? suggestionBeforeMutation,
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2151
|
+
return {
|
|
2152
|
+
status: 500,
|
|
2153
|
+
error: 'Unable to accept the suggested convention',
|
|
2154
|
+
suggestion: getProjectMemorySuggestionById(projectId, suggestionId) ?? suggestionBeforeMutation,
|
|
2155
|
+
};
|
|
1489
2156
|
}
|
|
1490
|
-
return {
|
|
1491
|
-
suggestion: updatedSuggestion,
|
|
1492
|
-
convention: createdConvention,
|
|
1493
|
-
};
|
|
1494
2157
|
};
|
|
1495
2158
|
export const applyProjectMemorySuggestionToDecision = (projectId, suggestionId, decisionId, overrides = {}) => {
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
2159
|
+
let suggestionBeforeMutation = null;
|
|
2160
|
+
const transaction = db.transaction(() => {
|
|
2161
|
+
const suggestion = getProjectMemorySuggestionById(projectId, suggestionId);
|
|
2162
|
+
suggestionBeforeMutation = suggestion;
|
|
2163
|
+
if (!suggestion) {
|
|
2164
|
+
return { status: 404, error: 'Suggestion not found' };
|
|
2165
|
+
}
|
|
2166
|
+
if (suggestion.status !== 'pending') {
|
|
2167
|
+
return { status: 409, error: 'Suggestion is no longer pending', suggestion };
|
|
2168
|
+
}
|
|
2169
|
+
const existingDecision = getProjectDecisionById(projectId, decisionId);
|
|
2170
|
+
if (!existingDecision || existingDecision.status !== ACCEPTED_PROJECT_DECISION_STATUS) {
|
|
2171
|
+
return { status: 404, error: 'Decision not found', suggestion };
|
|
2172
|
+
}
|
|
2173
|
+
const merged = mergeProjectMemorySuggestionDecisionValues(suggestion, overrides);
|
|
2174
|
+
if ('error' in merged) {
|
|
2175
|
+
return { status: 400, error: merged.error, suggestion };
|
|
2176
|
+
}
|
|
2177
|
+
const duplicate = classifyProjectMemorySuggestionDuplicate({
|
|
2178
|
+
suggestion: {
|
|
2179
|
+
kind: 'decision',
|
|
2180
|
+
title: merged.values.title ?? '',
|
|
2181
|
+
scope: merged.values.scope ?? null,
|
|
2182
|
+
decision: merged.values.decision ?? '',
|
|
2183
|
+
rationale: merged.values.rationale ?? null,
|
|
2184
|
+
implications: parseStringArray(merged.values.implications_json ?? null),
|
|
2185
|
+
stages: parseStringArray(merged.values.stages_json ?? null),
|
|
2186
|
+
path_globs: parseStringArray(merged.values.path_globs_json ?? null),
|
|
2187
|
+
component_tags: parseStringArray(merged.values.component_tags_json ?? null),
|
|
2188
|
+
evidence: suggestion.evidence,
|
|
2189
|
+
recommendation: suggestion.recommendation,
|
|
2190
|
+
},
|
|
2191
|
+
activeConventions: [],
|
|
2192
|
+
activeDecisions: listProjectDecisions(projectId).filter(decision => decision.id !== decisionId),
|
|
2193
|
+
pendingSuggestions: listProjectMemorySuggestionRows(projectId, { status: 'pending' })
|
|
2194
|
+
.filter(row => row.id !== suggestionId)
|
|
2195
|
+
.map(toProjectMemorySuggestionCandidate)
|
|
2196
|
+
.filter((candidate) => candidate !== null),
|
|
2197
|
+
});
|
|
2198
|
+
if (duplicate?.action === 'suppress' && duplicate.target === 'decision') {
|
|
2199
|
+
return { status: 409, error: 'Updating this decision would duplicate another accepted decision', suggestion };
|
|
2200
|
+
}
|
|
2201
|
+
const update = db.prepare(`
|
|
2202
|
+
UPDATE project_decisions
|
|
2203
|
+
SET title = ?,
|
|
2204
|
+
scope = ?,
|
|
2205
|
+
decision = ?,
|
|
2206
|
+
rationale = ?,
|
|
2207
|
+
implications_json = ?,
|
|
2208
|
+
updated_at = CURRENT_TIMESTAMP
|
|
2209
|
+
WHERE id = ?
|
|
2210
|
+
AND project_id = ?
|
|
2211
|
+
AND status = ?
|
|
2212
|
+
`).run(merged.values.title ?? existingDecision.title, merged.values.scope ?? null, merged.values.decision ?? existingDecision.decision, merged.values.rationale ?? null, merged.values.implications_json ?? (existingDecision.implications.length > 0 ? JSON.stringify(existingDecision.implications) : null), decisionId, projectId, ACCEPTED_PROJECT_DECISION_STATUS);
|
|
2213
|
+
if (update.changes !== 1) {
|
|
2214
|
+
return {
|
|
2215
|
+
status: 409,
|
|
2216
|
+
error: 'Decision changed before the suggestion could be applied',
|
|
2217
|
+
suggestion: getProjectMemorySuggestionById(projectId, suggestionId) ?? suggestion,
|
|
2218
|
+
};
|
|
2219
|
+
}
|
|
2220
|
+
const updatedDecision = getProjectDecisionById(projectId, decisionId);
|
|
2221
|
+
if (!updatedDecision) {
|
|
2222
|
+
throw new Error('Decision disappeared after suggestion application');
|
|
2223
|
+
}
|
|
2224
|
+
if (!resolvePendingProjectMemorySuggestion(projectId, suggestionId, 'applied_to_existing', updatedDecision.id)) {
|
|
2225
|
+
throw new ProjectMemoryLifecycleConflictError('Suggestion changed before the application could be resolved');
|
|
2226
|
+
}
|
|
2227
|
+
const appliedSuggestion = getProjectMemorySuggestionById(projectId, suggestionId);
|
|
2228
|
+
if (!appliedSuggestion) {
|
|
2229
|
+
throw new Error('Suggestion disappeared after application');
|
|
2230
|
+
}
|
|
2231
|
+
const sourceOrigin = createProjectMemorySuggestionOrigin(projectId, suggestion);
|
|
2232
|
+
recordProjectMemoryEvent({
|
|
2233
|
+
projectId,
|
|
2234
|
+
memoryKind: 'decision',
|
|
2235
|
+
memoryId: updatedDecision.id,
|
|
2236
|
+
eventType: 'suggestion_applied',
|
|
2237
|
+
sourceSuggestionId: suggestion.id,
|
|
2238
|
+
summary: sourceOrigin,
|
|
2239
|
+
});
|
|
2240
|
+
return { suggestion: appliedSuggestion, decision: updatedDecision };
|
|
2241
|
+
});
|
|
2242
|
+
try {
|
|
2243
|
+
return transaction();
|
|
1499
2244
|
}
|
|
1500
|
-
|
|
1501
|
-
|
|
2245
|
+
catch (error) {
|
|
2246
|
+
if (error instanceof ProjectMemoryLifecycleConflictError) {
|
|
2247
|
+
return {
|
|
2248
|
+
status: 409,
|
|
2249
|
+
error: error.message,
|
|
2250
|
+
suggestion: getProjectMemorySuggestionById(projectId, suggestionId) ?? suggestionBeforeMutation,
|
|
2251
|
+
};
|
|
2252
|
+
}
|
|
2253
|
+
return {
|
|
2254
|
+
status: 500,
|
|
2255
|
+
error: 'Unable to apply the suggestion to the existing decision',
|
|
2256
|
+
suggestion: getProjectMemorySuggestionById(projectId, suggestionId) ?? suggestionBeforeMutation,
|
|
2257
|
+
};
|
|
1502
2258
|
}
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
2259
|
+
};
|
|
2260
|
+
export const applyProjectMemorySuggestionToConvention = (projectId, suggestionId, conventionId, overrides = {}) => {
|
|
2261
|
+
const transaction = db.transaction(() => {
|
|
2262
|
+
const suggestion = getProjectMemorySuggestionById(projectId, suggestionId);
|
|
2263
|
+
if (!suggestion) {
|
|
2264
|
+
return { status: 404, error: 'Suggestion not found' };
|
|
2265
|
+
}
|
|
2266
|
+
if (suggestion.status !== 'pending') {
|
|
2267
|
+
return { status: 409, error: 'Suggestion is no longer pending', suggestion };
|
|
2268
|
+
}
|
|
2269
|
+
const existing = getProjectConventionById(projectId, conventionId);
|
|
2270
|
+
if (!existing || existing.status !== 'active') {
|
|
2271
|
+
return { status: 404, error: 'Convention not found', suggestion };
|
|
2272
|
+
}
|
|
2273
|
+
const merged = mergeProjectMemorySuggestionConventionValues(suggestion, overrides);
|
|
2274
|
+
if ('error' in merged) {
|
|
2275
|
+
return { status: merged.code === 'field_too_large' ? 413 : 400, ...merged, suggestion };
|
|
2276
|
+
}
|
|
2277
|
+
const update = db.prepare(`UPDATE project_conventions SET title = ?, scope = ?, instruction = ?, rationale = ?, stages_json = ?, path_globs_json = ?, component_tags_json = ?, priority = ?, injection_mode = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND project_id = ? AND status = 'active'`).run(merged.values.title, merged.values.scope, merged.values.instruction, merged.values.rationale, merged.values.stages_json, merged.values.path_globs_json, merged.values.component_tags_json, merged.values.priority, merged.values.injection_mode, conventionId, projectId);
|
|
2278
|
+
if (update.changes !== 1 || !resolvePendingProjectMemorySuggestion(projectId, suggestionId, 'applied_to_existing', conventionId)) {
|
|
2279
|
+
throw new ProjectMemoryLifecycleConflictError('Suggestion changed before the application could be resolved');
|
|
2280
|
+
}
|
|
2281
|
+
recordProjectMemoryEvent({ projectId, memoryKind: 'convention', memoryId: conventionId, eventType: 'suggestion_applied', sourceSuggestionId: suggestionId, summary: createProjectMemorySuggestionOrigin(projectId, suggestion) });
|
|
2282
|
+
const convention = getProjectConventionById(projectId, conventionId);
|
|
2283
|
+
const appliedSuggestion = getProjectMemorySuggestionById(projectId, suggestionId);
|
|
2284
|
+
if (!convention || !appliedSuggestion) {
|
|
2285
|
+
throw new Error('Convention disappeared after suggestion application');
|
|
2286
|
+
}
|
|
2287
|
+
return { suggestion: appliedSuggestion, convention };
|
|
2288
|
+
});
|
|
2289
|
+
try {
|
|
2290
|
+
return transaction();
|
|
1506
2291
|
}
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
return { status: 400, error: merged.error, suggestion };
|
|
2292
|
+
catch (error) {
|
|
2293
|
+
return { status: error instanceof ProjectMemoryLifecycleConflictError ? 409 : 500, error: error instanceof ProjectMemoryLifecycleConflictError ? error.message : 'Unable to apply the suggestion to the existing convention', suggestion: getProjectMemorySuggestionById(projectId, suggestionId) };
|
|
1510
2294
|
}
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
2295
|
+
};
|
|
2296
|
+
export const supersedeProjectDecisionFromSuggestion = (projectId, suggestionId, targetDecisionId, overrides = {}) => {
|
|
2297
|
+
const transaction = db.transaction(() => {
|
|
2298
|
+
const suggestion = getProjectMemorySuggestionById(projectId, suggestionId);
|
|
2299
|
+
if (!suggestion) {
|
|
2300
|
+
return { status: 404, error: 'Suggestion not found' };
|
|
2301
|
+
}
|
|
2302
|
+
if (suggestion.kind !== 'decision') {
|
|
2303
|
+
return { status: 400, error: 'Only decision suggestions can supersede a decision', suggestion };
|
|
2304
|
+
}
|
|
2305
|
+
if (suggestion.status !== 'pending') {
|
|
2306
|
+
return { status: 409, error: 'Suggestion is no longer pending', suggestion };
|
|
2307
|
+
}
|
|
2308
|
+
const target = getProjectDecisionById(projectId, targetDecisionId);
|
|
2309
|
+
if (!target || target.status !== ACCEPTED_PROJECT_DECISION_STATUS) {
|
|
2310
|
+
return { status: 404, error: 'Decision not found', suggestion };
|
|
2311
|
+
}
|
|
2312
|
+
const merged = mergeProjectMemorySuggestionDecisionValues(suggestion, overrides);
|
|
2313
|
+
if ('error' in merged) {
|
|
2314
|
+
return { status: merged.code === 'field_too_large' ? 413 : 400, ...merged, suggestion };
|
|
2315
|
+
}
|
|
2316
|
+
const origin = createProjectMemorySuggestionOrigin(projectId, suggestion);
|
|
2317
|
+
if (!origin) {
|
|
2318
|
+
return { status: 409, error: 'Suggestion source is no longer available for this project', suggestion };
|
|
2319
|
+
}
|
|
2320
|
+
const replacement = createProjectDecision(projectId, {
|
|
2321
|
+
title: merged.values.title ?? '', scope: merged.values.scope ?? null, decision: merged.values.decision ?? '', rationale: merged.values.rationale ?? null,
|
|
2322
|
+
implications_json: merged.values.implications_json ?? null, stages_json: merged.values.stages_json ?? null,
|
|
2323
|
+
path_globs_json: merged.values.path_globs_json ?? null, component_tags_json: merged.values.component_tags_json ?? null,
|
|
2324
|
+
}, origin);
|
|
2325
|
+
if (!replacement) {
|
|
2326
|
+
throw new Error('Unable to create replacement decision');
|
|
2327
|
+
}
|
|
2328
|
+
const superseded = db.prepare(`UPDATE project_decisions SET status = 'superseded', superseded_by_decision_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND project_id = ? AND status = 'accepted'`).run(replacement.id, targetDecisionId, projectId);
|
|
2329
|
+
if (superseded.changes !== 1 || !resolvePendingProjectMemorySuggestion(projectId, suggestionId, 'accepted', replacement.id)) {
|
|
2330
|
+
throw new ProjectMemoryLifecycleConflictError('Suggestion or target changed before supersession could be applied');
|
|
2331
|
+
}
|
|
2332
|
+
recordProjectMemoryEvent({ projectId, memoryKind: 'decision', memoryId: targetDecisionId, eventType: 'superseded', sourceSuggestionId: suggestionId, summary: { replacement_decision_id: replacement.id } });
|
|
2333
|
+
const resolved = getProjectMemorySuggestionById(projectId, suggestionId);
|
|
2334
|
+
if (!resolved) {
|
|
2335
|
+
throw new Error('Suggestion disappeared after supersession');
|
|
2336
|
+
}
|
|
2337
|
+
return { suggestion: resolved, decision: replacement };
|
|
1536
2338
|
});
|
|
1537
|
-
|
|
1538
|
-
return
|
|
2339
|
+
try {
|
|
2340
|
+
return transaction();
|
|
1539
2341
|
}
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
return { status: 500, error: 'Unable to update the suggestion state', suggestion };
|
|
2342
|
+
catch (error) {
|
|
2343
|
+
return { status: error instanceof ProjectMemoryLifecycleConflictError ? 409 : 500, error: error instanceof ProjectMemoryLifecycleConflictError ? error.message : 'Unable to supersede the decision from the suggestion', suggestion: getProjectMemorySuggestionById(projectId, suggestionId) };
|
|
1543
2344
|
}
|
|
1544
|
-
return {
|
|
1545
|
-
suggestion: updatedSuggestion,
|
|
1546
|
-
decision: updatedDecision,
|
|
1547
|
-
};
|
|
1548
2345
|
};
|
|
1549
2346
|
//# sourceMappingURL=projectMemory.js.map
|