@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.
Files changed (130) hide show
  1. package/dist/client/assets/{GitHubActionsDialog-DcW7fUgz.js → GitHubActionsDialog-oMY61JXF.js} +1 -1
  2. package/dist/client/assets/{TestsDialog-xNEttQOx.js → TestsDialog-elElGi9L.js} +3 -4
  3. package/dist/client/assets/{architectureDiagram-3BPJPVTR-DHADo63Y.js → architectureDiagram-3BPJPVTR-BNcdEMha.js} +1 -1
  4. package/dist/client/assets/{blockDiagram-GPEHLZMM-D_LqGLuD.js → blockDiagram-GPEHLZMM-xHTKb0-z.js} +1 -1
  5. package/dist/client/assets/{c4Diagram-AAUBKEIU-mIqv7fCU.js → c4Diagram-AAUBKEIU-kMngbYRg.js} +1 -1
  6. package/dist/client/assets/channel-DAW1OrrC.js +1 -0
  7. package/dist/client/assets/{chunk-2J33WTMH-DP-b68f-.js → chunk-2J33WTMH-BVZnJcYa.js} +1 -1
  8. package/dist/client/assets/{chunk-3OPIFGDE-D4GWQbqc.js → chunk-3OPIFGDE-Dq_H_MkG.js} +1 -1
  9. package/dist/client/assets/{chunk-5ZQYHXKU-HftAkT9j.js → chunk-5ZQYHXKU-_TwHLQwE.js} +1 -1
  10. package/dist/client/assets/{chunk-727SXJPM-CvB0JQZP.js → chunk-727SXJPM-CKkS_93w.js} +1 -1
  11. package/dist/client/assets/{chunk-AQP2D5EJ-DSB28EAb.js → chunk-AQP2D5EJ-BHSSO0SM.js} +1 -1
  12. package/dist/client/assets/{chunk-CSCIHK7Q-DuSgBocb.js → chunk-CSCIHK7Q-CgzsiMAL.js} +1 -1
  13. package/dist/client/assets/{chunk-KSCS5N6A-cm_ppJ7v.js → chunk-KSCS5N6A-DM-rnicK.js} +1 -1
  14. package/dist/client/assets/{chunk-L5ZTLDWV-BzDHIV9v.js → chunk-L5ZTLDWV-CnMgoXZs.js} +1 -1
  15. package/dist/client/assets/{chunk-LZXEDZCA-BruMwvko.js → chunk-LZXEDZCA-CCr2z1iU.js} +2 -2
  16. package/dist/client/assets/{chunk-ND2GUHAM-DrnvodkL.js → chunk-ND2GUHAM-DtA6gjMn.js} +1 -1
  17. package/dist/client/assets/{chunk-NZK2D7GU-QVsa0LuF.js → chunk-NZK2D7GU-_8fmSSaf.js} +1 -1
  18. package/dist/client/assets/{chunk-O5CBEL6O-DJFhq2gr.js → chunk-O5CBEL6O-B84elqwU.js} +1 -1
  19. package/dist/client/assets/{chunk-WU5MYG2G-ieLjE2uQ.js → chunk-WU5MYG2G-DBvmvPPt.js} +1 -1
  20. package/dist/client/assets/classDiagram-4FO5ZUOK-BmFh-n6n.js +1 -0
  21. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-tA1aAbh4.js +1 -0
  22. package/dist/client/assets/{dagre-BM42HDAG-CFRh82aT.js → dagre-BM42HDAG-C-QssoNU.js} +1 -1
  23. package/dist/client/assets/{diagram-2AECGRRQ-CPk1WJvx.js → diagram-2AECGRRQ-8RpG5DGb.js} +1 -1
  24. package/dist/client/assets/{diagram-5GNKFQAL-DHMJ735r.js → diagram-5GNKFQAL-Dk2raJWq.js} +1 -1
  25. package/dist/client/assets/{diagram-KO2AKTUF-Cq9d2BWb.js → diagram-KO2AKTUF-7iZuIIef.js} +1 -1
  26. package/dist/client/assets/{diagram-LMA3HP47-CgWJTR_b.js → diagram-LMA3HP47-JLaaaEE6.js} +1 -1
  27. package/dist/client/assets/{diagram-OG6HWLK6-HoFYGw81.js → diagram-OG6HWLK6-jeL3-Rd3.js} +1 -1
  28. package/dist/client/assets/{erDiagram-TEJ5UH35-B7yuPZck.js → erDiagram-TEJ5UH35-B6uFrMrQ.js} +1 -1
  29. package/dist/client/assets/{flowDiagram-I6XJVG4X-DaXXRHQj.js → flowDiagram-I6XJVG4X-BFWnhmx2.js} +1 -1
  30. package/dist/client/assets/{ganttDiagram-6RSMTGT7-pKsFmtHB.js → ganttDiagram-6RSMTGT7-nxON-689.js} +1 -1
  31. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-Dwx5kc1M.js → gitGraphDiagram-PVQCEYII-DEAsC0GZ.js} +1 -1
  32. package/dist/client/assets/index-BzDXl4AF.js +265 -0
  33. package/dist/client/assets/index-DSap5tSX.css +2 -0
  34. package/dist/client/assets/{infoDiagram-5YYISTIA-6Taupp-l.js → infoDiagram-5YYISTIA-CAZHR2I8.js} +1 -1
  35. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-BBKjiFtW.js → ishikawaDiagram-YF4QCWOH-DEe4SNzl.js} +1 -1
  36. package/dist/client/assets/{journeyDiagram-JHISSGLW-BQQ1EKJ3.js → journeyDiagram-JHISSGLW-CG2kTAj9.js} +1 -1
  37. package/dist/client/assets/{kanban-definition-UN3LZRKU-BvsroFzw.js → kanban-definition-UN3LZRKU-DUWutAjA.js} +1 -1
  38. package/dist/client/assets/{line-C6BHsie7.js → line-B1SFN8mz.js} +1 -1
  39. package/dist/client/assets/{mermaid-parser.core-Cb4G8u3H.js → mermaid-parser.core-C-dyz_nb.js} +1 -1
  40. package/dist/client/assets/{mermaid.core-NCO8Z4wR.js → mermaid.core-nm4BMZuF.js} +3 -3
  41. package/dist/client/assets/{mindmap-definition-RKZ34NQL-DYzSjAq3.js → mindmap-definition-RKZ34NQL-DZEd-RCz.js} +1 -1
  42. package/dist/client/assets/{pieDiagram-4H26LBE5-CWMyFFuU.js → pieDiagram-4H26LBE5-WXxrzUwX.js} +1 -1
  43. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-D1ifIucc.js → quadrantDiagram-W4KKPZXB-BClH2kQx.js} +1 -1
  44. package/dist/client/assets/{requirementDiagram-4Y6WPE33-DW68fC21.js → requirementDiagram-4Y6WPE33-ByolUxfP.js} +1 -1
  45. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-DnnV47BL.js → sankeyDiagram-5OEKKPKP-B2vcb3U1.js} +1 -1
  46. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-Dw-9ZBU_.js → sequenceDiagram-3UESZ5HK-J8Nt0GXG.js} +1 -1
  47. package/dist/client/assets/{stateDiagram-AJRCARHV-C7dkf8nk.js → stateDiagram-AJRCARHV-CrgZ4Uwa.js} +1 -1
  48. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CUEBQOsD.js +1 -0
  49. package/dist/client/assets/{timeline-definition-PNZ67QCA-BmVfK_16.js → timeline-definition-PNZ67QCA-CXebl6LI.js} +1 -1
  50. package/dist/client/assets/{vennDiagram-CIIHVFJN-B9j3QbmD.js → vennDiagram-CIIHVFJN-DLBqI39h.js} +1 -1
  51. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-C520auMy.js → wardleyDiagram-YWT4CUSO-ku4fViV9.js} +1 -1
  52. package/dist/client/assets/{xychartDiagram-2RQKCTM6-CPqUJuqs.js → xychartDiagram-2RQKCTM6-5Jh-bBeb.js} +1 -1
  53. package/dist/client/index.html +2 -2
  54. package/dist/server/db.js +291 -3
  55. package/dist/server/db.js.map +1 -1
  56. package/dist/server/index.js +339 -97
  57. package/dist/server/index.js.map +1 -1
  58. package/dist/server/lib/agent.js +853 -161
  59. package/dist/server/lib/agent.js.map +1 -1
  60. package/dist/server/lib/agentRuns.js +22 -0
  61. package/dist/server/lib/agentRuns.js.map +1 -1
  62. package/dist/server/lib/chatMessages.js +24 -8
  63. package/dist/server/lib/chatMessages.js.map +1 -1
  64. package/dist/server/lib/genericToolCall.js +151 -0
  65. package/dist/server/lib/genericToolCall.js.map +1 -0
  66. package/dist/server/lib/git.js +18 -0
  67. package/dist/server/lib/git.js.map +1 -1
  68. package/dist/server/lib/mobileAccess.js +45 -2
  69. package/dist/server/lib/mobileAccess.js.map +1 -1
  70. package/dist/server/lib/models.js +65 -48
  71. package/dist/server/lib/models.js.map +1 -1
  72. package/dist/server/lib/opencodeTaskSessions.js +216 -0
  73. package/dist/server/lib/opencodeTaskSessions.js.map +1 -0
  74. package/dist/server/lib/projectMemory.js +1073 -276
  75. package/dist/server/lib/projectMemory.js.map +1 -1
  76. package/dist/server/lib/projectMemoryCursor.js +27 -0
  77. package/dist/server/lib/projectMemoryCursor.js.map +1 -0
  78. package/dist/server/lib/projectMemoryEvents.js +24 -0
  79. package/dist/server/lib/projectMemoryEvents.js.map +1 -0
  80. package/dist/server/lib/projectMemoryLimits.js +61 -0
  81. package/dist/server/lib/projectMemoryLimits.js.map +1 -0
  82. package/dist/server/lib/projectMemoryPrompt.js +299 -110
  83. package/dist/server/lib/projectMemoryPrompt.js.map +1 -1
  84. package/dist/server/lib/projectMemoryScan.js +461 -493
  85. package/dist/server/lib/projectMemoryScan.js.map +1 -1
  86. package/dist/server/lib/projectMemorySelections.js +97 -0
  87. package/dist/server/lib/projectMemorySelections.js.map +1 -0
  88. package/dist/server/lib/projectMemorySuggestions.js +19 -4
  89. package/dist/server/lib/projectMemorySuggestions.js.map +1 -1
  90. package/dist/server/lib/projectMemoryTransfer.js +635 -83
  91. package/dist/server/lib/projectMemoryTransfer.js.map +1 -1
  92. package/dist/server/lib/projectPathGlob.js +40 -0
  93. package/dist/server/lib/projectPathGlob.js.map +1 -0
  94. package/dist/server/lib/projects.js +50 -0
  95. package/dist/server/lib/projects.js.map +1 -1
  96. package/dist/server/lib/promptSkills.js +5 -0
  97. package/dist/server/lib/promptSkills.js.map +1 -1
  98. package/dist/server/lib/specMessages.js +9 -6
  99. package/dist/server/lib/specMessages.js.map +1 -1
  100. package/dist/server/lib/specReviewContext.js +2 -1
  101. package/dist/server/lib/specReviewContext.js.map +1 -1
  102. package/dist/server/lib/ticketMessages.js +22 -7
  103. package/dist/server/lib/ticketMessages.js.map +1 -1
  104. package/dist/server/lib/tickets.js +6 -0
  105. package/dist/server/lib/tickets.js.map +1 -1
  106. package/dist/server/lib/transcriptRuns.js +85 -0
  107. package/dist/server/lib/transcriptRuns.js.map +1 -0
  108. package/dist/server/workers/build.js +18 -4
  109. package/dist/server/workers/build.js.map +1 -1
  110. package/dist/server/workers/chat.js +24 -7
  111. package/dist/server/workers/chat.js.map +1 -1
  112. package/dist/server/workers/followUp.js +27 -10
  113. package/dist/server/workers/followUp.js.map +1 -1
  114. package/dist/server/workers/plan.js +405 -69
  115. package/dist/server/workers/plan.js.map +1 -1
  116. package/dist/server/workers/planFollowUp.js +30 -7
  117. package/dist/server/workers/planFollowUp.js.map +1 -1
  118. package/dist/server/workers/review.js +76 -20
  119. package/dist/server/workers/review.js.map +1 -1
  120. package/dist/server/workers/specReview.js +10 -0
  121. package/dist/server/workers/specReview.js.map +1 -1
  122. package/dist/server/workers/specSession.js +37 -3
  123. package/dist/server/workers/specSession.js.map +1 -1
  124. package/package.json +1 -1
  125. package/dist/client/assets/channel-D7PWuiXU.js +0 -1
  126. package/dist/client/assets/classDiagram-4FO5ZUOK-BNIT931s.js +0 -1
  127. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-TRtc0GGS.js +0 -1
  128. package/dist/client/assets/index-5Eun897r.js +0 -260
  129. package/dist/client/assets/index-CWWEw76C.css +0 -2
  130. 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
- source_stage: null,
289
- ticket_id: null,
290
- chat_session_id: null,
291
- spec_session_id: null,
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
- source_stage: null,
316
- ticket_id: null,
317
- chat_session_id: null,
318
- spec_session_id: null,
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.toLowerCase(),
438
- suggestion.scope?.toLowerCase() ?? '',
439
- getProjectMemorySuggestionContent(suggestion).toLowerCase(),
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.stages.join(','), suggestion.priority);
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 { error: decisionValidation.error };
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 { error: conventionValidation.error };
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.prepare(`
1022
- INSERT INTO project_conventions (id, project_id, title, scope, instruction, rationale, stages_json, priority, injection_mode, status)
1023
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active')
1024
- `).run(id, projectId, values.title, values.scope, values.instruction, values.rationale, values.stages_json, values.priority, values.injection_mode);
1025
- return getProjectConventionById(projectId, id);
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 current = getProjectConventionById(projectId, conventionId);
1029
- if (!current) {
1030
- return null;
1031
- }
1032
- db.prepare(`
1033
- UPDATE project_conventions
1034
- SET title = ?,
1035
- scope = ?,
1036
- instruction = ?,
1037
- rationale = ?,
1038
- stages_json = ?,
1039
- priority = ?,
1040
- injection_mode = ?,
1041
- updated_at = CURRENT_TIMESTAMP
1042
- WHERE id = ?
1043
- AND project_id = ?
1044
- `).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), values.priority ?? current.priority, values.injection_mode ?? current.injection_mode, conventionId, projectId);
1045
- return getProjectConventionById(projectId, conventionId);
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
- db.prepare(`
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
- db.prepare(`
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.prepare(`
1068
- INSERT INTO project_decisions (id, project_id, title, scope, decision, rationale, implications_json, status)
1069
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1070
- `).run(id, projectId, values.title, values.scope, values.decision, values.rationale, values.implications_json, ACCEPTED_PROJECT_DECISION_STATUS);
1071
- return getProjectDecisionById(projectId, id);
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 current = getProjectDecisionById(projectId, decisionId);
1075
- if (!current) {
1076
- return null;
1077
- }
1078
- db.prepare(`
1079
- UPDATE project_decisions
1080
- SET title = ?,
1081
- scope = ?,
1082
- decision = ?,
1083
- rationale = ?,
1084
- implications_json = ?,
1085
- updated_at = CURRENT_TIMESTAMP
1086
- WHERE id = ?
1087
- AND project_id = ?
1088
- `).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), decisionId, projectId);
1089
- return getProjectDecisionById(projectId, decisionId);
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
- db.prepare(`
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
- db.prepare(`
1102
- UPDATE project_decisions
1103
- SET status = 'superseded', updated_at = CURRENT_TIMESTAMP
1104
- WHERE id = ?
1105
- AND project_id = ?
1106
- `).run(decisionId, projectId);
1107
- return getProjectDecisionById(projectId, decisionId);
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
- db.prepare(`
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, suggestion.kind, suggestion.title, suggestion.scope, getProjectMemorySuggestionContent(suggestion), suggestion.rationale, JSON.stringify(suggestion.kind === 'decision'
1317
- ? { implications: suggestion.implications }
1318
- : { stages: suggestion.stages, priority: suggestion.priority, injection_mode: suggestion.injection_mode }), duplicate?.action === 'mark_near_duplicate' ? 'near_duplicate' : 'none', duplicate?.action === 'mark_near_duplicate' ? duplicate.targetMemoryId : null);
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, ...suggestion });
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 updateProjectMemorySuggestionStatus = (projectId, suggestionId, status, resolvedMemoryId) => {
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 getProjectMemorySuggestionById(projectId, suggestionId);
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
- return updateProjectMemorySuggestionStatus(projectId, suggestionId, 'dismissed', null);
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
- return suggestion.kind === 'decision'
1387
- ? acceptProjectMemorySuggestionAsDecision(projectId, suggestionId, overrides)
1388
- : acceptProjectMemorySuggestionAsConvention(projectId, suggestionId, overrides);
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 acceptProjectMemorySuggestionAsDecision = (projectId, suggestionId, overrides = {}) => {
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
- if (suggestion.status !== 'pending') {
1396
- return { status: 409, error: 'Suggestion is no longer pending', suggestion };
1397
- }
1398
- const merged = mergeProjectMemorySuggestionDecisionValues(suggestion, overrides);
1399
- if ('error' in merged) {
1400
- return { status: 400, error: merged.error, suggestion };
1401
- }
1402
- const duplicate = classifyProjectMemorySuggestionDuplicate({
1403
- suggestion: {
1404
- kind: 'decision',
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
- implications: parseStringArray(merged.values.implications_json ?? null),
1410
- },
1411
- activeConventions: [],
1412
- activeDecisions: listProjectDecisions(projectId),
1413
- pendingSuggestions: listProjectMemorySuggestionRows(projectId, { status: 'pending' })
1414
- .filter(row => row.id !== suggestionId)
1415
- .map(toProjectMemorySuggestionCandidate)
1416
- .filter((candidate) => candidate !== null),
1417
- });
1418
- if (duplicate?.action === 'suppress' && duplicate.target === 'decision') {
1419
- return { status: 409, error: 'A matching decision already exists for this project', suggestion };
1420
- }
1421
- const createdDecision = createProjectDecision(projectId, {
1422
- title: merged.values.title ?? '',
1423
- scope: merged.values.scope ?? null,
1424
- decision: merged.values.decision ?? '',
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
- if (!createdDecision) {
1429
- return { status: 500, error: 'Unable to create the suggested decision', suggestion };
2043
+ try {
2044
+ return transaction();
1430
2045
  }
1431
- const updatedSuggestion = updateProjectMemorySuggestionStatus(projectId, suggestionId, 'accepted', createdDecision.id);
1432
- if (!updatedSuggestion) {
1433
- return { status: 500, error: 'Unable to update the suggestion state', suggestion };
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
- const suggestion = getProjectMemorySuggestionById(projectId, suggestionId);
1442
- if (!suggestion) {
1443
- return { status: 404, error: 'Suggestion not found' };
1444
- }
1445
- if (suggestion.status !== 'pending') {
1446
- return { status: 409, error: 'Suggestion is no longer pending', suggestion };
1447
- }
1448
- const merged = mergeProjectMemorySuggestionConventionValues(suggestion, overrides);
1449
- if ('error' in merged) {
1450
- return { status: 400, error: merged.error, suggestion };
1451
- }
1452
- const duplicate = classifyProjectMemorySuggestionDuplicate({
1453
- suggestion: {
1454
- kind: 'convention',
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
- stages: parseStringArray(merged.values.stages_json ?? null)
1460
- .filter((entry) => PROJECT_MEMORY_STAGES.includes(entry)),
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
- activeConventions: listProjectConventions(projectId),
1465
- activeDecisions: [],
1466
- pendingSuggestions: listProjectMemorySuggestionRows(projectId, { status: 'pending' })
1467
- .filter(row => row.id !== suggestionId)
1468
- .map(toProjectMemorySuggestionCandidate)
1469
- .filter((candidate) => candidate !== null),
1470
- });
1471
- if (duplicate?.action === 'suppress' && duplicate.target === 'convention') {
1472
- return { status: 409, error: 'A matching convention already exists for this project', suggestion };
1473
- }
1474
- const createdConvention = createProjectConvention(projectId, {
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
- if (!createdConvention) {
1484
- return { status: 500, error: 'Unable to create the suggested convention', suggestion };
2140
+ try {
2141
+ return transaction();
1485
2142
  }
1486
- const updatedSuggestion = updateProjectMemorySuggestionStatus(projectId, suggestionId, 'accepted', createdConvention.id);
1487
- if (!updatedSuggestion) {
1488
- return { status: 500, error: 'Unable to update the suggestion state', suggestion };
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
- const suggestion = getProjectMemorySuggestionById(projectId, suggestionId);
1497
- if (!suggestion) {
1498
- return { status: 404, error: 'Suggestion not found' };
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
- if (suggestion.status !== 'pending') {
1501
- return { status: 409, error: 'Suggestion is no longer pending', suggestion };
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
- const existingDecision = getProjectDecisionById(projectId, decisionId);
1504
- if (!existingDecision || existingDecision.status !== ACCEPTED_PROJECT_DECISION_STATUS) {
1505
- return { status: 404, error: 'Decision not found', suggestion };
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
- const merged = mergeProjectMemorySuggestionDecisionValues(suggestion, overrides);
1508
- if ('error' in merged) {
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
- const duplicate = classifyProjectMemorySuggestionDuplicate({
1512
- suggestion: {
1513
- kind: 'decision',
1514
- title: merged.values.title ?? '',
1515
- scope: merged.values.scope ?? null,
1516
- decision: merged.values.decision ?? '',
1517
- rationale: merged.values.rationale ?? null,
1518
- implications: parseStringArray(merged.values.implications_json ?? null),
1519
- },
1520
- activeConventions: [],
1521
- activeDecisions: listProjectDecisions(projectId).filter(decision => decision.id !== decisionId),
1522
- pendingSuggestions: listProjectMemorySuggestionRows(projectId, { status: 'pending' })
1523
- .filter(row => row.id !== suggestionId)
1524
- .map(toProjectMemorySuggestionCandidate)
1525
- .filter((candidate) => candidate !== null),
1526
- });
1527
- if (duplicate?.action === 'suppress' && duplicate.target === 'decision') {
1528
- return { status: 409, error: 'Updating this decision would duplicate another accepted decision', suggestion };
1529
- }
1530
- const updatedDecision = updateProjectDecision(projectId, decisionId, {
1531
- title: merged.values.title ?? existingDecision.title,
1532
- scope: merged.values.scope ?? null,
1533
- decision: merged.values.decision ?? existingDecision.decision,
1534
- rationale: merged.values.rationale ?? null,
1535
- implications_json: merged.values.implications_json ?? (existingDecision.implications.length > 0 ? JSON.stringify(existingDecision.implications) : null),
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
- if (!updatedDecision) {
1538
- return { status: 500, error: 'Unable to update the existing decision', suggestion };
2339
+ try {
2340
+ return transaction();
1539
2341
  }
1540
- const updatedSuggestion = updateProjectMemorySuggestionStatus(projectId, suggestionId, 'applied_to_existing', updatedDecision.id);
1541
- if (!updatedSuggestion) {
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