@yemi33/minions 0.1.2424 → 0.1.2426

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 (185) hide show
  1. package/bin/install-internal-minions.js +1209 -0
  2. package/bin/minions.js +75 -9
  3. package/dashboard/docs/typography.md +27 -12
  4. package/dashboard/js/command-center.js +13 -11
  5. package/dashboard/js/live-stream.js +1 -1
  6. package/dashboard/js/memory-search.js +388 -43
  7. package/dashboard/js/modal-qa.js +5 -5
  8. package/dashboard/js/qa.js +39 -26
  9. package/dashboard/js/refresh.js +37 -18
  10. package/dashboard/js/render-agents.js +14 -18
  11. package/dashboard/js/render-dispatch.js +14 -74
  12. package/dashboard/js/render-inbox.js +3 -3
  13. package/dashboard/js/render-meetings.js +8 -8
  14. package/dashboard/js/render-other.js +45 -20
  15. package/dashboard/js/render-pipelines.js +6 -6
  16. package/dashboard/js/render-plans.js +14 -14
  17. package/dashboard/js/render-prd.js +46 -46
  18. package/dashboard/js/render-prs.js +9 -65
  19. package/dashboard/js/render-schedules.js +5 -5
  20. package/dashboard/js/render-skills.js +6 -6
  21. package/dashboard/js/render-utils.js +4 -4
  22. package/dashboard/js/render-watches.js +4 -16
  23. package/dashboard/js/render-work-items.js +95 -107
  24. package/dashboard/js/settings.js +175 -74
  25. package/dashboard/layout.html +3 -2
  26. package/dashboard/pages/inbox.html +1 -1
  27. package/dashboard/pages/work.html +1 -1
  28. package/dashboard/shared/model-display.js +9 -0
  29. package/dashboard/shared/watches-source.js +43 -0
  30. package/dashboard/shared/welcome-popup.js +259 -0
  31. package/dashboard/slim/body.html +6 -6
  32. package/dashboard/slim/js/chat.js +4 -2
  33. package/dashboard/slim/js/history.js +10 -3
  34. package/dashboard/slim/js/modals-tiles.js +85 -16
  35. package/dashboard/slim/js/status.js +12 -16
  36. package/dashboard/slim/styles.css +24 -4
  37. package/dashboard/styles.css +62 -27
  38. package/dashboard-build.js +8 -2
  39. package/dashboard.js +3854 -2514
  40. package/docs/README.md +7 -5
  41. package/docs/api-errors.md +144 -0
  42. package/docs/auto-discovery.md +83 -61
  43. package/docs/capture-demos.js +173 -30
  44. package/docs/command-center.md +5 -1
  45. package/docs/completion-reports.md +127 -11
  46. package/docs/constants.md +15 -3
  47. package/docs/constellation-style-telemetry.md +2 -2
  48. package/docs/copilot-cli-schema.md +32 -22
  49. package/docs/cross-repo-plans.md +30 -30
  50. package/docs/demo/memory-system.html +1 -1
  51. package/docs/deprecated.json +132 -18
  52. package/docs/diagnostics-memory.md +8 -4
  53. package/docs/human-vs-automated.md +2 -2
  54. package/docs/index.html +9 -2
  55. package/docs/internal-install.md +212 -0
  56. package/docs/kb-pr3223-cascade-archiving.md +16 -0
  57. package/docs/kb-pr696-merge-conflict-docs.md +23 -0
  58. package/docs/kb-sweep.md +2 -2
  59. package/docs/keep-processes.md +8 -1
  60. package/docs/live-checkout-mode.md +1 -1
  61. package/docs/managed-spawn.md +21 -3
  62. package/docs/named-agents.md +3 -2
  63. package/docs/onboarding.md +26 -0
  64. package/docs/plan-lifecycle.md +32 -32
  65. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
  66. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
  67. package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
  68. package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
  69. package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
  70. package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
  71. package/docs/qa-runbook-lifecycle.md +67 -51
  72. package/docs/qa-runbooks.md +2 -2
  73. package/docs/runtime-adapters.md +101 -50
  74. package/docs/security.md +28 -0
  75. package/docs/self-improvement.md +47 -13
  76. package/docs/skills.md +29 -0
  77. package/docs/slim-ux/concepts.md +2 -2
  78. package/docs/specs/agent-configurability.md +43 -41
  79. package/docs/specs/agent-rename.md +13 -13
  80. package/docs/team-memory.md +283 -14
  81. package/docs/tutorials/01-install-and-connect.md +23 -0
  82. package/docs/tutorials/03-plan-driven-feature.md +3 -3
  83. package/docs/tutorials/04-runtimes-and-harness.md +18 -21
  84. package/docs/tutorials/08-operations-and-recovery.md +3 -2
  85. package/docs/tutorials/README.md +4 -3
  86. package/docs/visual-evidence-ci.md +103 -0
  87. package/docs/watches.md +35 -30
  88. package/docs/worktree-lifecycle.md +151 -13
  89. package/engine/acp-transport.js +1 -0
  90. package/engine/ado.js +80 -33
  91. package/engine/agent-api-validation.js +571 -0
  92. package/engine/agent-worker-pool.js +7 -6
  93. package/engine/api-contracts/agent-content.js +761 -0
  94. package/engine/api-contracts/cc-ops.js +219 -0
  95. package/engine/api-contracts/config-runtime.js +419 -0
  96. package/engine/api-contracts/core.js +138 -0
  97. package/engine/api-contracts/index.js +506 -0
  98. package/engine/api-contracts/orchestration.js +602 -0
  99. package/engine/api-contracts/pull-requests.js +323 -0
  100. package/engine/api-contracts/qa-process.js +764 -0
  101. package/engine/api-contracts/work-plan-prd.js +494 -0
  102. package/engine/api-validation.js +543 -0
  103. package/engine/cc-api-validation.js +586 -0
  104. package/engine/cc-worker-pool.js +9 -1
  105. package/engine/cleanup.js +393 -271
  106. package/engine/cli.js +65 -7
  107. package/engine/comment-classifier.js +70 -5
  108. package/engine/consolidation.js +123 -5
  109. package/engine/content-api-validation.js +606 -0
  110. package/engine/create-pr-worktree.js +22 -1
  111. package/engine/db/migrations/027-review-learning-lifecycle.js +162 -0
  112. package/engine/dispatch.js +33 -0
  113. package/engine/distribution.js +189 -0
  114. package/engine/execution-model.js +59 -0
  115. package/engine/features.js +12 -0
  116. package/engine/github.js +67 -25
  117. package/engine/harness-context.js +313 -0
  118. package/engine/inbox-store.js +1 -1
  119. package/engine/kb-sweep.js +161 -38
  120. package/engine/keep-process-sweep.js +43 -3
  121. package/engine/lifecycle.js +1066 -165
  122. package/engine/llm.js +4 -0
  123. package/engine/managed-spawn.js +463 -48
  124. package/engine/meeting.js +197 -34
  125. package/engine/memory-retrieval.js +280 -0
  126. package/engine/memory-store.js +821 -38
  127. package/engine/model-discovery.js +17 -3
  128. package/engine/pipeline.js +802 -14
  129. package/engine/plan-prd-validation.js +745 -0
  130. package/engine/playbook.js +206 -17
  131. package/engine/pooled-agent-process.js +6 -1
  132. package/engine/pr-action.js +1 -1
  133. package/engine/pr-issue-validation.js +653 -0
  134. package/engine/pr-resolve.js +75 -13
  135. package/engine/prd-store.js +39 -6
  136. package/engine/preflight.js +7 -2
  137. package/engine/process-utils.js +79 -31
  138. package/engine/projects.js +198 -12
  139. package/engine/promotion.js +371 -0
  140. package/engine/qa-from-prd.js +3 -0
  141. package/engine/qa-process-validation.js +572 -0
  142. package/engine/qa-runbooks.js +41 -37
  143. package/engine/qa-runners/maestro.js +11 -7
  144. package/engine/qa-runners/playwright.js +37 -14
  145. package/engine/qa-runners.js +9 -10
  146. package/engine/qa-runs.js +124 -11
  147. package/engine/qa-sessions.js +139 -40
  148. package/engine/quarantine-refs.js +81 -11
  149. package/engine/queries.js +163 -9
  150. package/engine/review-learning-backfill.js +482 -0
  151. package/engine/review-learning.js +1236 -0
  152. package/engine/runtimes/claude.js +60 -2
  153. package/engine/runtimes/codex.js +57 -16
  154. package/engine/runtimes/contract.js +21 -0
  155. package/engine/runtimes/copilot.js +51 -3
  156. package/engine/runtimes/index.js +1 -0
  157. package/engine/scheduler.js +201 -32
  158. package/engine/settings-validation.js +909 -0
  159. package/engine/shared.js +705 -208
  160. package/engine/spawn-agent.js +48 -21
  161. package/engine/steering-constraints.js +31 -0
  162. package/engine/steering-store.js +10 -2
  163. package/engine/steering.js +14 -2
  164. package/engine/timeout.js +404 -111
  165. package/engine/untrusted-fence.js +24 -10
  166. package/engine/watch-actions.js +7 -2
  167. package/engine/watches.js +353 -51
  168. package/engine/work-item-validation.js +561 -0
  169. package/engine/work-items-store.js +61 -1
  170. package/engine/worktree-gc.js +656 -37
  171. package/engine/worktree-preflight.js +154 -0
  172. package/engine.js +2068 -342
  173. package/package.json +1 -1
  174. package/playbooks/_pr-description-audit.md +88 -41
  175. package/playbooks/build-fix-complex.md +3 -3
  176. package/playbooks/docs.md +2 -2
  177. package/playbooks/fix.md +18 -18
  178. package/playbooks/implement.md +18 -18
  179. package/playbooks/qa-session-draft.md +10 -12
  180. package/playbooks/qa-session-execute.md +16 -12
  181. package/playbooks/qa-validate.md +8 -7
  182. package/playbooks/shared-rules.md +45 -0
  183. package/playbooks/test.md +4 -0
  184. package/prompts/cc-system.md +1 -1
  185. package/skills/check-self-authored-review-comment/SKILL.md +2 -2
@@ -3,12 +3,21 @@
3
3
  const crypto = require('crypto');
4
4
  const { getDb, withTransaction, prepareCached } = require('./db');
5
5
  const { emitStateEvent } = require('./db-events');
6
+ const reviewLearning = require('./review-learning');
6
7
 
7
8
  const MEMORY_TYPES = new Set(['semantic', 'episodic', 'procedural']);
8
9
  const SCOPE_TYPES = new Set(['global', 'project', 'agent']);
9
- const STATUSES = new Set(['active', 'superseded', 'retracted', 'expired']);
10
+ const STATUSES = new Set(['active', 'candidate', 'superseded', 'retracted', 'expired']);
11
+ const ACTIONS = new Set(['pin', 'retract', 'restore']);
10
12
  const TRUST_LEVELS = new Set(['human', 'system', 'agent', 'external']);
11
- const MAX_RETRIEVAL_RUNS = 5000;
13
+ // Source-bound identity fields that a candidate transition must never allow a
14
+ // caller to rewrite through `options` — they bind the stored candidate to its
15
+ // finding/PR/author provenance and are reapplied from the stored row.
16
+ const IMMUTABLE_CANDIDATE_METADATA = Object.freeze([
17
+ 'kind', 'findingKey', 'prId', 'authorAgent', 'reviewerAgent', 'sourceHash',
18
+ ]);
19
+ const MAX_RETRIEVAL_RUNS = reviewLearning.REVIEW_LEARNING_BOUNDS.retrievalRuns;
20
+ const MAX_LIFECYCLE_EVENTS = reviewLearning.REVIEW_LEARNING_BOUNDS.lifecycleEvents;
12
21
  const QUERY_STOP_WORDS = new Set([
13
22
  'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'in', 'is',
14
23
  'it', 'of', 'on', 'or', 'that', 'the', 'this', 'to', 'with',
@@ -113,13 +122,24 @@ function upsertMemoryRecord(input) {
113
122
  const record = _normalize(input);
114
123
  const db = getDb();
115
124
  withTransaction(db, () => {
116
- if (!record.supersedesId) {
125
+ if (record.status === 'active') {
117
126
  const prior = db.prepare(`
118
127
  SELECT id FROM memory_records
119
- WHERE source_type=? AND source_ref=? AND status='active' AND content_hash<>?
120
- ORDER BY updated_at DESC LIMIT 1
121
- `).get(record.sourceType, record.sourceRef, record.contentHash);
122
- if (prior) record.supersedesId = prior.id;
128
+ WHERE source_type=? AND source_ref=? AND status='active'
129
+ AND content_hash<>? AND id<>?
130
+ ORDER BY updated_at DESC, id LIMIT 1
131
+ `).get(record.sourceType, record.sourceRef, record.contentHash, record.id);
132
+ if (!record.supersedesId && prior) record.supersedesId = prior.id;
133
+ if (prior) {
134
+ db.prepare(`
135
+ UPDATE memory_records
136
+ SET status='superseded', valid_to=?, superseded_by=?, updated_at=?
137
+ WHERE source_type=? AND source_ref=? AND status='active' AND id<>?
138
+ `).run(
139
+ record.validFrom, record.id, record.updatedAt,
140
+ record.sourceType, record.sourceRef, record.id,
141
+ );
142
+ }
123
143
  }
124
144
  if (record.supersedesId) {
125
145
  db.prepare(`
@@ -174,37 +194,97 @@ function getMemoryRecord(id) {
174
194
  function updateMemoryRecordState(id, action) {
175
195
  const recordId = String(id || '').trim();
176
196
  if (!recordId) throw new TypeError('memory record id is required');
177
- if (!['pin', 'retract', 'restore'].includes(action)) {
197
+ if (!ACTIONS.has(action)) {
178
198
  throw new TypeError(`unsupported memory action: ${action}`);
179
199
  }
180
- if (!getMemoryRecord(recordId)) return null;
200
+ const existing = getMemoryRecord(recordId);
201
+ if (!existing) return null;
202
+ if (existing.status === 'candidate' && action !== 'retract') {
203
+ throw new TypeError(`memory candidate ${recordId} requires a verified disposition transition`);
204
+ }
181
205
 
182
206
  const db = getDb();
183
207
  const now = Date.now();
184
- if (action === 'pin') {
185
- db.prepare(`
186
- UPDATE memory_records
187
- SET trust='human', importance=1, confidence=MAX(confidence, 0.9),
188
- status='active', valid_to=NULL, superseded_by=NULL, updated_at=?
189
- WHERE id=?
190
- `).run(now, recordId);
191
- } else if (action === 'restore') {
192
- db.prepare(`
193
- UPDATE memory_records
194
- SET status='active', valid_to=NULL, superseded_by=NULL, updated_at=?
195
- WHERE id=?
196
- `).run(now, recordId);
197
- } else {
198
- db.prepare(`
199
- UPDATE memory_records
200
- SET status='retracted', valid_to=?, updated_at=?
201
- WHERE id=?
202
- `).run(now, now, recordId);
203
- }
208
+ // Detect whether the operator action is a real state transition. Idempotency
209
+ // of human actions (AC: "repeated operator actions do not duplicate lifecycle
210
+ // events") is provided by only emitting a lifecycle event when the record
211
+ // actually changed status/trust; re-applying the same action is a no-op event.
212
+ const changed = action === 'pin'
213
+ ? !(existing.trust === 'human' && existing.status === 'active'
214
+ && existing.validTo == null && existing.supersededBy == null)
215
+ : action === 'restore'
216
+ ? (existing.status !== 'active' || existing.validTo != null || existing.supersededBy != null)
217
+ : existing.status !== 'retracted';
218
+ withTransaction(db, () => {
219
+ if (action === 'pin') {
220
+ _supersedeOtherActiveVersions(db, existing, now);
221
+ db.prepare(`
222
+ UPDATE memory_records
223
+ SET trust='human', importance=1, confidence=MAX(confidence, 0.9),
224
+ status='active', valid_to=NULL, superseded_by=NULL, updated_at=?
225
+ WHERE id=?
226
+ `).run(now, recordId);
227
+ } else if (action === 'restore') {
228
+ _supersedeOtherActiveVersions(db, existing, now);
229
+ db.prepare(`
230
+ UPDATE memory_records
231
+ SET status='active', valid_to=NULL, superseded_by=NULL, updated_at=?
232
+ WHERE id=?
233
+ `).run(now, recordId);
234
+ } else {
235
+ db.prepare(`
236
+ UPDATE memory_records
237
+ SET status='retracted', valid_to=?, updated_at=?
238
+ WHERE id=?
239
+ `).run(now, now, recordId);
240
+ }
241
+ });
204
242
  emitStateEvent('memory_records', { id: recordId, action });
243
+ // Every operator pin/retract/restore of a review-learning record emits one
244
+ // compact lifecycle event (pinned/retracted/restored). The `changed` gate
245
+ // provides idempotency (repeated actions do not duplicate events); tagging
246
+ // project/area from the record (area || 'general') keeps the retracted/restored
247
+ // events in the same (project, area) group as the original `created` event so
248
+ // the diagnostics rollout aggregates and operator modal timeline (P-1ed2fb5f)
249
+ // report accurate counts.
250
+ if (changed && existing.sourceType === reviewLearning.REVIEW_LEARNING_SOURCE_TYPE) {
251
+ // Best-effort: an audit event must never break the operator control itself.
252
+ try {
253
+ recordMemoryLifecycleEvent({
254
+ eventType: reviewLearning.REVIEW_LEARNING_HUMAN_ACTION_EVENTS[action],
255
+ memoryId: recordId,
256
+ findingKey: existing.sourceRef,
257
+ project: existing.scopeKey,
258
+ agent: 'operator',
259
+ area: existing.metadata?.area || 'general',
260
+ disposition: existing.metadata?.disposition || null,
261
+ // A per-transition timestamp keeps a legitimate retract→restore→retract
262
+ // cycle as distinct events while pure replays are gated out above.
263
+ identity: `operator:${action}:${now}`,
264
+ metadata: { action, trigger: 'operator', priorStatus: existing.status },
265
+ createdAt: now,
266
+ });
267
+ } catch (err) {
268
+ emitStateEvent('memory_records', { id: recordId, action, lifecycleEventError: err.message });
269
+ }
270
+ }
205
271
  return getMemoryRecord(recordId);
206
272
  }
207
273
 
274
+ // Reactivating a versioned record (pin/restore) must preserve the
275
+ // single-active-version invariant that upsertMemoryRecord already enforces on
276
+ // insert: supersede any OTHER active version sharing the same source lineage
277
+ // (source_type + source_ref — e.g. a review-learning finding key) so restoring
278
+ // or pinning an older version can never leave two active rows recallable.
279
+ function _supersedeOtherActiveVersions(db, record, now) {
280
+ if (!record || record.sourceRef == null || String(record.sourceRef).trim() === '') return;
281
+ db.prepare(`
282
+ UPDATE memory_records
283
+ SET status='superseded', valid_to=?, superseded_by=?, updated_at=?
284
+ WHERE source_type=? AND source_ref=? AND status='active' AND id<>?
285
+ `).run(now, record.id, now, record.sourceType, record.sourceRef, record.id);
286
+ }
287
+
208
288
  function listMemoryRecords(filters = {}) {
209
289
  const where = [];
210
290
  const args = [];
@@ -213,6 +293,8 @@ function listMemoryRecords(filters = {}) {
213
293
  ['memory_type', filters.memoryType],
214
294
  ['scope_type', filters.scopeType],
215
295
  ['scope_key', filters.scopeKey],
296
+ ['source_type', filters.sourceType],
297
+ ['source_ref', filters.sourceRef || filters.findingKey],
216
298
  ]) {
217
299
  if (value == null || value === '') continue;
218
300
  where.push(`${column}=?`);
@@ -242,6 +324,14 @@ function searchMemoryRecords(query, options = {}) {
242
324
  "(m.memory_type<>'procedural' OR m.trust IN ('human','system'))",
243
325
  ];
244
326
  const args = [match];
327
+ // Review-learning records are recalled exclusively through the dedicated
328
+ // deterministic project+audience path (retrieveApplicableReviewLearnings).
329
+ // They are unconditionally excluded from generic FTS-driven recall so a
330
+ // review lesson can never leak into the generic Relevant Memory pack. The
331
+ // operator inspection endpoint opts back in via includeReviewLearning.
332
+ if (!options.includeReviewLearning) {
333
+ where.push(`m.source_type<>'${reviewLearning.REVIEW_LEARNING_SOURCE_TYPE}'`);
334
+ }
245
335
  const statuses = Array.isArray(options.statuses)
246
336
  ? options.statuses.filter(status => STATUSES.has(status))
247
337
  : ['active'];
@@ -267,6 +357,24 @@ function searchMemoryRecords(query, options = {}) {
267
357
  args.push(...types);
268
358
  }
269
359
  }
360
+ // Diagnostics filters (P-1ed2fb5f). Applied inside the SQL WHERE so the LIMIT
361
+ // is honored after narrowing — post-filtering would silently drop matches.
362
+ if (options.sourceType) {
363
+ where.push('m.source_type=?');
364
+ args.push(String(options.sourceType));
365
+ }
366
+ if (options.findingKey) {
367
+ where.push('m.source_ref=?');
368
+ args.push(String(options.findingKey));
369
+ }
370
+ if (options.author) {
371
+ where.push("json_extract(m.metadata,'$.authorAgent')=?");
372
+ args.push(String(options.author));
373
+ }
374
+ if (options.audience) {
375
+ where.push("json_extract(m.metadata,'$.audience')=?");
376
+ args.push(String(options.audience));
377
+ }
270
378
  const limit = Math.max(1, Math.min(200, Number(options.limit) || 50));
271
379
  args.push(limit);
272
380
  // CROSS JOIN pins FTS as the outer loop; Node 22's SQLite otherwise prefers
@@ -320,25 +428,109 @@ function supersedeMatchingRecords(options = {}) {
320
428
  return changed;
321
429
  }
322
430
 
323
- function recordRetrievalRun(run) {
431
+ function _boundedRetrievalText(value, maxBytes, label) {
432
+ if (value == null || String(value).trim() === '') return null;
433
+ return reviewLearning.redactBoundedText(value, maxBytes, label);
434
+ }
435
+
436
+ function _retrievalRow(row) {
437
+ if (!row) return null;
438
+ return {
439
+ id: row.id,
440
+ workItemId: row.work_item_id,
441
+ dispatchId: row.dispatch_id,
442
+ project: row.project,
443
+ agent: row.agent,
444
+ retrievalKind: row.retrieval_kind || 'general',
445
+ query: row.query,
446
+ candidateCount: row.candidate_count,
447
+ selectedCount: row.selected_count,
448
+ selectedBytes: row.selected_bytes,
449
+ durationMs: row.duration_ms,
450
+ shadow: row.shadow === 1,
451
+ selectedIds: _parseJson(row.selected_ids, []),
452
+ decisionTrace: _parseJson(row.decision_trace, []),
453
+ createdAt: row.created_at,
454
+ };
455
+ }
456
+
457
+ function recordRetrievalRun(run = {}) {
458
+ reviewLearning.assertNoProhibitedReviewPayload(run);
324
459
  const db = getDb();
460
+ const selectedIds = reviewLearning.normalizeSelectedIds(run.selectedIds);
461
+ const decisionTrace = reviewLearning.normalizeDecisionTrace(run.decisionTrace);
462
+ const retrievalKind = reviewLearning.normalizeRetrievalKind(run.retrievalKind);
463
+ const createdAt = Number.isFinite(Number(run.createdAt)) ? Number(run.createdAt) : Date.now();
464
+ let insertedId;
325
465
  withTransaction(db, () => {
326
- db.prepare(`
466
+ const result = db.prepare(`
327
467
  INSERT INTO memory_retrieval_runs (
328
- work_item_id, project, agent, query, candidate_count, selected_count,
329
- selected_bytes, duration_ms, shadow, created_at
330
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
468
+ work_item_id, dispatch_id, project, agent, retrieval_kind, query,
469
+ candidate_count, selected_count, selected_bytes, duration_ms, shadow,
470
+ selected_ids, decision_trace, created_at
471
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
331
472
  `).run(
332
- run.workItemId || null, run.project || null, run.agent || null,
333
- String(run.query || ''), Number(run.candidateCount) || 0,
334
- Number(run.selectedCount) || 0, Number(run.selectedBytes) || 0,
335
- Number(run.durationMs) || 0, run.shadow ? 1 : 0, Date.now(),
473
+ _boundedRetrievalText(run.workItemId, 320, 'retrieval work item id'),
474
+ _boundedRetrievalText(run.dispatchId, 320, 'retrieval dispatch id'),
475
+ _boundedRetrievalText(run.project, 120, 'retrieval project'),
476
+ _boundedRetrievalText(run.agent, 96, 'retrieval agent'),
477
+ retrievalKind,
478
+ reviewLearning.redactBoundedText(
479
+ run.query || '',
480
+ reviewLearning.REVIEW_LEARNING_BOUNDS.queryBytes,
481
+ 'retrieval query',
482
+ ),
483
+ Math.max(0, Math.min(1000000, Math.floor(Number(run.candidateCount) || 0))),
484
+ Math.max(0, Math.min(1000000, Math.floor(Number(run.selectedCount) || 0))),
485
+ Math.max(0, Math.min(100000000, Math.floor(Number(run.selectedBytes) || 0))),
486
+ Math.max(0, Math.min(3600000, Number(run.durationMs) || 0)),
487
+ run.shadow ? 1 : 0,
488
+ _json(selectedIds, []),
489
+ _json(decisionTrace, []),
490
+ createdAt,
336
491
  );
492
+ insertedId = result.lastInsertRowid;
337
493
  db.prepare(`
338
494
  DELETE FROM memory_retrieval_runs
339
495
  WHERE id NOT IN (SELECT id FROM memory_retrieval_runs ORDER BY id DESC LIMIT ?)
340
496
  `).run(MAX_RETRIEVAL_RUNS);
341
497
  });
498
+ return _retrievalRow(db.prepare('SELECT * FROM memory_retrieval_runs WHERE id=?').get(insertedId));
499
+ }
500
+
501
+ function getLatestRetrievalTrace(filters = {}) {
502
+ const where = [];
503
+ const args = [];
504
+ for (const [column, value] of [
505
+ ['dispatch_id', filters.dispatchId],
506
+ ['work_item_id', filters.workItemId],
507
+ ['retrieval_kind', filters.retrievalKind],
508
+ ['project', filters.project],
509
+ ]) {
510
+ if (value == null || String(value).trim() === '') continue;
511
+ where.push(`${column}=?`);
512
+ args.push(String(value));
513
+ }
514
+ const sql = `SELECT * FROM memory_retrieval_runs${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT 1`;
515
+ return _retrievalRow(prepareCached(getDb(), sql).get(...args));
516
+ }
517
+
518
+ function listRetrievalTraces(filters = {}) {
519
+ const where = [];
520
+ const args = [];
521
+ for (const [column, value] of [
522
+ ['dispatch_id', filters.dispatchId],
523
+ ['work_item_id', filters.workItemId],
524
+ ['retrieval_kind', filters.retrievalKind],
525
+ ['project', filters.project],
526
+ ]) {
527
+ if (value == null || String(value).trim() === '') continue;
528
+ where.push(`${column}=?`);
529
+ args.push(String(value));
530
+ }
531
+ const limit = Math.max(1, Math.min(200, Number(filters.limit) || 50));
532
+ const sql = `SELECT * FROM memory_retrieval_runs${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ?`;
533
+ return prepareCached(getDb(), sql).all(...args, limit).map(_retrievalRow);
342
534
  }
343
535
 
344
536
  function getRetrievalStats() {
@@ -352,10 +544,585 @@ function getRetrievalStats() {
352
544
  `).get();
353
545
  }
354
546
 
547
+ function upsertReviewLearningCandidate(input) {
548
+ const record = _normalize(reviewLearning.buildCandidateMemoryRecord(input));
549
+ const db = getDb();
550
+ let result;
551
+ withTransaction(db, () => {
552
+ const existing = _row(prepareCached(db, 'SELECT * FROM memory_records WHERE id=?').get(record.id));
553
+ if (existing && existing.status !== 'candidate') {
554
+ result = existing;
555
+ return;
556
+ }
557
+ if (existing) {
558
+ record.createdAt = existing.createdAt;
559
+ record.validFrom = existing.validFrom;
560
+ record.validTo = existing.validTo;
561
+ record.supersedesId = existing.supersedesId;
562
+ record.supersededBy = existing.supersededBy;
563
+ record.metadata = {
564
+ ...existing.metadata,
565
+ ...record.metadata,
566
+ candidateState: existing.metadata.candidateState || record.metadata.candidateState,
567
+ dispatchId: existing.metadata.dispatchId || record.metadata.dispatchId || null,
568
+ workItemId: existing.metadata.workItemId || record.metadata.workItemId || null,
569
+ };
570
+ }
571
+ result = upsertMemoryRecord(record);
572
+ });
573
+ return result;
574
+ }
575
+
576
+ function upsertReviewLearningLesson(input) {
577
+ const record = _normalize(reviewLearning.buildLessonMemoryRecord(input));
578
+ const existing = getMemoryRecord(record.id);
579
+ if (existing?.status === 'active') {
580
+ record.createdAt = existing.createdAt;
581
+ record.validFrom = existing.validFrom;
582
+ record.supersedesId = existing.supersedesId;
583
+ record.supersededBy = existing.supersededBy;
584
+ }
585
+ return upsertMemoryRecord(record);
586
+ }
587
+
588
+ function listReviewLearningCandidates(filters = {}) {
589
+ const records = listMemoryRecords({
590
+ status: 'candidate',
591
+ scopeType: filters.project ? 'project' : filters.scopeType,
592
+ scopeKey: filters.project || filters.scopeKey,
593
+ limit: Math.max(1, Math.min(500, Number(filters.limit) || 100)),
594
+ });
595
+ return records.filter(record => (
596
+ record.sourceType === reviewLearning.REVIEW_LEARNING_SOURCE_TYPE
597
+ || record.metadata.legacyUnverified === true
598
+ ) && (!filters.findingKey
599
+ || record.sourceRef === filters.findingKey
600
+ || record.metadata.findingKey === filters.findingKey));
601
+ }
602
+
603
+ function listActiveReviewLearnings(filters = {}) {
604
+ const where = [
605
+ "source_type='review-learning'",
606
+ "memory_type='procedural'",
607
+ "scope_type='project'",
608
+ "status='active'",
609
+ ];
610
+ const args = [];
611
+ if (filters.project) {
612
+ where.push('scope_key=?');
613
+ args.push(reviewLearning.normalizeProjectKey(filters.project));
614
+ }
615
+ if (filters.findingKey) {
616
+ where.push('source_ref=?');
617
+ args.push(reviewLearning.normalizeFindingKey(filters.findingKey));
618
+ }
619
+ const limit = Math.max(1, Math.min(500, Number(filters.limit) || 100));
620
+ const sql = `SELECT * FROM memory_records WHERE ${where.join(' AND ')} ORDER BY updated_at DESC, id LIMIT ?`;
621
+ const records = prepareCached(getDb(), sql).all(...args, limit).map(_row);
622
+ if (!filters.audience) return records;
623
+ return records.filter(record => record.metadata.audience === filters.audience);
624
+ }
625
+
626
+ function transitionReviewLearningCandidate(findingKey, disposition, options = {}) {
627
+ reviewLearning.assertNoProhibitedReviewPayload(options);
628
+ const normalizedFindingKey = reviewLearning.normalizeFindingKey(findingKey);
629
+ const transition = reviewLearning.candidateTransitionFor(disposition);
630
+ const lessonId = String(options.lessonId || '').trim();
631
+ if (transition.requiresLesson && !lessonId) {
632
+ throw new TypeError(`${transition.disposition} candidate transition requires a lesson id`);
633
+ }
634
+ const db = getDb();
635
+ const [findingProject] = normalizedFindingKey.split('|');
636
+ const now = Date.now();
637
+ const updated = [];
638
+ withTransaction(db, () => {
639
+ const rows = db.prepare(`
640
+ SELECT * FROM memory_records
641
+ WHERE source_type='review-learning' AND source_ref=? AND status='candidate'
642
+ AND scope_type='project' AND scope_key=?
643
+ ORDER BY updated_at DESC, id
644
+ `).all(normalizedFindingKey, findingProject);
645
+ if (!rows.length) return;
646
+ if (transition.requiresLesson) {
647
+ const lesson = db.prepare(`
648
+ SELECT * FROM memory_records
649
+ WHERE id=? AND source_type='review-learning' AND memory_type='procedural'
650
+ AND scope_type='project' AND scope_key=? AND source_ref=? AND status='active'
651
+ `).get(lessonId, findingProject, normalizedFindingKey);
652
+ const lessonMetadata = lesson ? _parseJson(lesson.metadata, {}) : {};
653
+ if (
654
+ !lesson
655
+ || lessonMetadata.kind !== 'review-learning-lesson'
656
+ || lessonMetadata.findingKey !== normalizedFindingKey
657
+ ) {
658
+ throw new TypeError(
659
+ `${transition.disposition} candidate transition requires an active review-learning lesson for the same finding and project`,
660
+ );
661
+ }
662
+ }
663
+ const statement = db.prepare(`
664
+ UPDATE memory_records
665
+ SET status=?, valid_to=?, superseded_by=?, metadata=?, updated_at=?
666
+ WHERE id=? AND status='candidate'
667
+ `);
668
+ for (const row of rows) {
669
+ const storedMetadata = _parseJson(row.metadata, {});
670
+ const provenance = {};
671
+ for (const key of IMMUTABLE_CANDIDATE_METADATA) {
672
+ if (storedMetadata[key] !== undefined) provenance[key] = storedMetadata[key];
673
+ }
674
+ const metadata = reviewLearning.normalizeReviewMetadata({
675
+ ...storedMetadata,
676
+ ...options,
677
+ ...(transition.requiresLesson ? { lessonId } : {}),
678
+ disposition: transition.disposition,
679
+ candidateState: transition.candidateState,
680
+ // Reapply source-bound identity last so caller `options` can never
681
+ // rewrite the candidate's immutable provenance during a transition.
682
+ ...provenance,
683
+ });
684
+ const result = statement.run(
685
+ transition.status,
686
+ transition.terminal ? now : row.valid_to,
687
+ transition.requiresLesson ? lessonId : row.superseded_by,
688
+ _json(metadata, {}),
689
+ now,
690
+ row.id,
691
+ );
692
+ if (result.changes) updated.push(row.id);
693
+ }
694
+ });
695
+ if (updated.length) {
696
+ emitStateEvent('memory_records', {
697
+ findingKey: normalizedFindingKey,
698
+ disposition: transition.disposition,
699
+ changed: updated.length,
700
+ });
701
+ }
702
+ return updated.map(getMemoryRecord);
703
+ }
704
+
705
+ // Quarantine an active review-learning lesson through the retracted lifecycle.
706
+ // Used by evidence-backed contradictions (agent-driven) and distinct from the
707
+ // generic operator control in updateMemoryRecordState. Provenance is retained:
708
+ // only status/valid_to move and the contradiction detail is merged into
709
+ // metadata without dropping existing keys. Idempotent — retracting an
710
+ // already-retracted lesson is a no-op.
711
+ function retractReviewLearningLesson(lessonId, options = {}) {
712
+ reviewLearning.assertNoProhibitedReviewPayload(options);
713
+ const recordId = String(lessonId || '').trim();
714
+ if (!recordId) throw new TypeError('review-learning lesson id is required');
715
+ const db = getDb();
716
+ const now = Date.now();
717
+ let changed = false;
718
+ let mergedMetadata = null;
719
+ withTransaction(db, () => {
720
+ const row = db.prepare(`
721
+ SELECT * FROM memory_records
722
+ WHERE id=? AND source_type='review-learning' AND memory_type='procedural'
723
+ AND status='active'
724
+ `).get(recordId);
725
+ if (!row) return;
726
+ const metadata = reviewLearning.normalizeReviewMetadata({
727
+ ..._parseJson(row.metadata, {}),
728
+ retractReason: options.reason || 'contradicted',
729
+ ...(options.contradictedBy ? { contradictedBy: options.contradictedBy } : {}),
730
+ ...(options.dispatchId ? { retractDispatchId: options.dispatchId } : {}),
731
+ ...(options.workItemId ? { retractWorkItemId: options.workItemId } : {}),
732
+ ...(Array.isArray(options.evidenceKinds) && options.evidenceKinds.length
733
+ ? { retractEvidenceKinds: options.evidenceKinds }
734
+ : {}),
735
+ });
736
+ const result = db.prepare(`
737
+ UPDATE memory_records
738
+ SET status='retracted', valid_to=?, metadata=?, updated_at=?
739
+ WHERE id=? AND status='active'
740
+ `).run(now, _json(metadata, {}), now, recordId);
741
+ changed = result.changes > 0;
742
+ if (changed) mergedMetadata = metadata;
743
+ });
744
+ if (changed) {
745
+ emitStateEvent('memory_records', {
746
+ id: recordId,
747
+ status: 'retracted',
748
+ reason: options.reason || 'contradicted',
749
+ });
750
+ }
751
+ return { record: getMemoryRecord(recordId), changed, metadata: mergedMetadata };
752
+ }
753
+
754
+ function _lifecycleRow(row) {
755
+ if (!row) return null;
756
+ return {
757
+ id: row.id,
758
+ eventKey: row.event_key,
759
+ eventType: row.event_type,
760
+ memoryId: row.memory_id,
761
+ findingKey: row.finding_key,
762
+ project: row.project,
763
+ agent: row.agent,
764
+ area: row.area,
765
+ disposition: row.disposition,
766
+ workItemId: row.work_item_id,
767
+ dispatchId: row.dispatch_id,
768
+ metadata: _parseJson(row.metadata, {}),
769
+ createdAt: row.created_at,
770
+ };
771
+ }
772
+
773
+ function recordMemoryLifecycleEvent(input) {
774
+ const event = reviewLearning.normalizeLifecycleEvent(input);
775
+ const db = getDb();
776
+ let inserted = false;
777
+ withTransaction(db, () => {
778
+ const result = db.prepare(`
779
+ INSERT OR IGNORE INTO memory_lifecycle_events (
780
+ event_key, event_type, memory_id, finding_key, project, agent, area,
781
+ disposition, work_item_id, dispatch_id, metadata, created_at
782
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
783
+ `).run(
784
+ event.eventKey, event.eventType, event.memoryId, event.findingKey,
785
+ event.project, event.agent, event.area, event.disposition,
786
+ event.workItemId, event.dispatchId, _json(event.metadata, {}),
787
+ event.createdAt,
788
+ );
789
+ inserted = result.changes > 0;
790
+ db.prepare(`
791
+ DELETE FROM memory_lifecycle_events
792
+ WHERE id NOT IN (
793
+ SELECT id FROM memory_lifecycle_events ORDER BY id DESC LIMIT ?
794
+ )
795
+ `).run(MAX_LIFECYCLE_EVENTS);
796
+ });
797
+ const stored = _lifecycleRow(
798
+ db.prepare('SELECT * FROM memory_lifecycle_events WHERE event_key=?').get(event.eventKey),
799
+ );
800
+ if (inserted) emitStateEvent('memory_lifecycle_events', { eventKey: event.eventKey, eventType: event.eventType });
801
+ return stored;
802
+ }
803
+
804
+ function listMemoryLifecycleEvents(filters = {}) {
805
+ const where = [];
806
+ const args = [];
807
+ for (const [column, value] of [
808
+ ['event_type', filters.eventType],
809
+ ['memory_id', filters.memoryId],
810
+ ['finding_key', filters.findingKey],
811
+ ['project', filters.project],
812
+ ['agent', filters.agent],
813
+ ['area', filters.area],
814
+ ['disposition', filters.disposition],
815
+ ['work_item_id', filters.workItemId],
816
+ ['dispatch_id', filters.dispatchId],
817
+ ]) {
818
+ if (value == null || String(value).trim() === '') continue;
819
+ where.push(`${column}=?`);
820
+ args.push(String(value));
821
+ }
822
+ const limit = Math.max(1, Math.min(500, Number(filters.limit) || 100));
823
+ const sql = `SELECT * FROM memory_lifecycle_events${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ?`;
824
+ return prepareCached(getDb(), sql).all(...args, limit).map(_lifecycleRow);
825
+ }
826
+
827
+ function pruneMemoryLifecycleEvents(options = {}) {
828
+ const maxRows = Math.max(1, Math.min(
829
+ MAX_LIFECYCLE_EVENTS,
830
+ Math.floor(Number(options.maxRows) || MAX_LIFECYCLE_EVENTS),
831
+ ));
832
+ // Compact lifecycle events are retained for at most one year (in addition to
833
+ // the row cap) and are deliberately decoupled from KB file TTL (P-1ed2fb5f).
834
+ const maxAgeMs = Number.isFinite(Number(options.maxAgeMs))
835
+ ? Math.max(0, Number(options.maxAgeMs))
836
+ : reviewLearning.REVIEW_LEARNING_BOUNDS.lifecycleEventMaxAgeMs;
837
+ const now = Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now();
838
+ const db = getDb();
839
+ let changed = 0;
840
+ withTransaction(db, () => {
841
+ changed += Number(db.prepare(`
842
+ DELETE FROM memory_lifecycle_events
843
+ WHERE id NOT IN (
844
+ SELECT id FROM memory_lifecycle_events ORDER BY id DESC LIMIT ?
845
+ )
846
+ `).run(maxRows).changes) || 0;
847
+ if (maxAgeMs > 0) {
848
+ changed += Number(db.prepare(`
849
+ DELETE FROM memory_lifecycle_events WHERE created_at < ?
850
+ `).run(now - maxAgeMs).changes) || 0;
851
+ }
852
+ });
853
+ if (changed) emitStateEvent('memory_lifecycle_events', { pruned: changed });
854
+ return changed;
855
+ }
856
+
857
+ // Unresolved review-learning candidates (still in the `candidate` state) expire
858
+ // after 180 days so a never-promoted finding does not linger forever. Active
859
+ // procedural lessons are untouched — they persist until superseded or retracted
860
+ // (P-1ed2fb5f, acceptance criterion 5).
861
+ function expireStaleReviewLearningCandidates(options = {}) {
862
+ const maxAgeMs = Number.isFinite(Number(options.maxAgeMs))
863
+ ? Math.max(0, Number(options.maxAgeMs))
864
+ : reviewLearning.REVIEW_LEARNING_BOUNDS.candidateExpiryMs;
865
+ const now = Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now();
866
+ const cutoff = now - maxAgeMs;
867
+ const db = getDb();
868
+ let changed = 0;
869
+ withTransaction(db, () => {
870
+ changed = Number(db.prepare(`
871
+ UPDATE memory_records
872
+ SET status='expired', valid_to=?, updated_at=?
873
+ WHERE status='candidate'
874
+ AND source_type='review-learning'
875
+ AND updated_at < ?
876
+ `).run(now, now, cutoff).changes) || 0;
877
+ });
878
+ if (changed) emitStateEvent('memory_records', { expiredCandidates: changed });
879
+ return changed;
880
+ }
881
+
882
+ // Bounded per-finding / per-memory event-type counters for the operator modal.
883
+ function getReviewLearningLifecycleSummary(filters = {}) {
884
+ const where = [];
885
+ const args = [];
886
+ for (const [column, value] of [
887
+ ['finding_key', filters.findingKey],
888
+ ['memory_id', filters.memoryId],
889
+ ['project', filters.project],
890
+ ['agent', filters.agent],
891
+ ['area', filters.area],
892
+ ]) {
893
+ if (value == null || String(value).trim() === '') continue;
894
+ where.push(`${column}=?`);
895
+ args.push(String(value));
896
+ }
897
+ const rows = prepareCached(getDb(), `
898
+ SELECT event_type, COUNT(*) AS count
899
+ FROM memory_lifecycle_events
900
+ ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
901
+ GROUP BY event_type
902
+ ORDER BY event_type
903
+ LIMIT 64
904
+ `).all(...args);
905
+ const counters = {};
906
+ let total = 0;
907
+ for (const row of rows) {
908
+ counters[row.event_type] = Number(row.count) || 0;
909
+ total += Number(row.count) || 0;
910
+ }
911
+ return { counters, total };
912
+ }
913
+
914
+ function getReviewLearningRolloutAggregates(options = {}) {
915
+ const where = [];
916
+ const args = [];
917
+ if (options.project) {
918
+ where.push('project=?');
919
+ args.push(reviewLearning.normalizeProjectKey(options.project));
920
+ }
921
+ if (options.area) {
922
+ where.push('area=?');
923
+ args.push(reviewLearning.normalizeReviewArea(options.area));
924
+ }
925
+ if (Number.isFinite(Number(options.from))) {
926
+ where.push('created_at>=?');
927
+ args.push(Number(options.from));
928
+ }
929
+ if (Number.isFinite(Number(options.to))) {
930
+ where.push('created_at<?');
931
+ args.push(Number(options.to));
932
+ }
933
+ const positive = [...reviewLearning.POSITIVE_DISPOSITIONS];
934
+ const sql = `
935
+ SELECT project, area,
936
+ COUNT(DISTINCT CASE
937
+ WHEN event_type='created' AND disposition IN (${positive.map(() => '?').join(',')})
938
+ THEN finding_key END
939
+ ) AS created_findings,
940
+ COUNT(DISTINCT CASE
941
+ WHEN event_type='retrieved' THEN finding_key END
942
+ ) AS retrieved_findings,
943
+ COUNT(DISTINCT CASE
944
+ WHEN event_type='applied' THEN finding_key END
945
+ ) AS applied_findings,
946
+ COUNT(DISTINCT CASE
947
+ WHEN event_type='retracted' THEN finding_key END
948
+ ) AS retracted_findings,
949
+ COUNT(DISTINCT CASE
950
+ WHEN event_type='repeated-finding' THEN finding_key END
951
+ ) AS repeated_findings
952
+ FROM memory_lifecycle_events
953
+ ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
954
+ GROUP BY project, area
955
+ ORDER BY project, area
956
+ LIMIT 500
957
+ `;
958
+ const minimumSample = Math.max(1, Math.min(10000, Math.floor(Number(options.minimumSample) || 20)));
959
+ return prepareCached(getDb(), sql).all(...positive, ...args).map(row => {
960
+ const createdFindings = Number(row.created_findings) || 0;
961
+ const retrievedFindings = Number(row.retrieved_findings) || 0;
962
+ const appliedFindings = Number(row.applied_findings) || 0;
963
+ const retractedFindings = Number(row.retracted_findings) || 0;
964
+ const repeatedFindings = Number(row.repeated_findings) || 0;
965
+ const sampleSufficient = createdFindings >= minimumSample;
966
+ return {
967
+ project: row.project,
968
+ area: row.area,
969
+ createdFindings,
970
+ retrievedFindings,
971
+ appliedFindings,
972
+ retractedFindings,
973
+ repeatedFindings,
974
+ // Plan's project-and-area rollout formula: recurrence rate is the share of
975
+ // distinct created findings that recurred as a repeated-finding event.
976
+ rate: createdFindings ? repeatedFindings / createdFindings : null,
977
+ minimumSample,
978
+ sampleSufficient,
979
+ status: sampleSufficient ? 'measured' : 'insufficient-sample',
980
+ };
981
+ });
982
+ }
983
+
984
+ // Staged-rollout success gate (P-507157f8). Compares a capture-only baseline
985
+ // window against the first active-recall window per project and normalized area.
986
+ // The rollout may only claim success for a (project, area) when BOTH windows have
987
+ // at least `minimumSample` (default 20) resolved valid findings AND the repeated
988
+ // valid-finding rate fell by at least `minReduction` (default 0.25 = 25%). Any
989
+ // window short of the sample floor is reported `insufficient-sample`, never a
990
+ // success. This is the measurement the docs' expansion criteria depend on: it is
991
+ // deliberately conservative so a small or noisy sample can never green-light
992
+ // broadening the recall canary.
993
+ const ROLLOUT_DEFAULT_MIN_SAMPLE = 20;
994
+ const ROLLOUT_DEFAULT_MIN_REDUCTION = 0.25;
995
+
996
+ function _rolloutAggregateKey(row) {
997
+ return `${row.project}\u0000${row.area}`;
998
+ }
999
+
1000
+ function getReviewLearningRolloutComparison(options = {}) {
1001
+ const minimumSample = Math.max(
1002
+ 1,
1003
+ Math.min(10000, Math.floor(Number(options.minimumSample) || ROLLOUT_DEFAULT_MIN_SAMPLE)),
1004
+ );
1005
+ const rawReduction = Number(options.minReduction);
1006
+ const minReduction = Number.isFinite(rawReduction)
1007
+ ? Math.max(0, Math.min(1, rawReduction))
1008
+ : ROLLOUT_DEFAULT_MIN_REDUCTION;
1009
+ const shared = { project: options.project, area: options.area, minimumSample };
1010
+ const baselineRows = getReviewLearningRolloutAggregates({
1011
+ ...shared,
1012
+ from: options.baselineFrom,
1013
+ to: options.baselineTo,
1014
+ });
1015
+ const activeRows = getReviewLearningRolloutAggregates({
1016
+ ...shared,
1017
+ from: options.activeFrom,
1018
+ to: options.activeTo,
1019
+ });
1020
+ const baselineByKey = new Map(baselineRows.map(row => [_rolloutAggregateKey(row), row]));
1021
+ const activeByKey = new Map(activeRows.map(row => [_rolloutAggregateKey(row), row]));
1022
+ const keys = new Set([...baselineByKey.keys(), ...activeByKey.keys()]);
1023
+ const results = [];
1024
+ for (const key of keys) {
1025
+ const baseline = baselineByKey.get(key) || null;
1026
+ const active = activeByKey.get(key) || null;
1027
+ const [project, area] = key.split('\u0000');
1028
+ const baselineSufficient = !!(baseline && baseline.sampleSufficient);
1029
+ const activeSufficient = !!(active && active.sampleSufficient);
1030
+ const baselineRate = baseline ? baseline.rate : null;
1031
+ const activeRate = active ? active.rate : null;
1032
+ let reduction = null;
1033
+ let success = false;
1034
+ let status = 'insufficient-sample';
1035
+ if (baselineSufficient && activeSufficient) {
1036
+ // A zero baseline rate means there was nothing to improve on; that can
1037
+ // never satisfy a "rate fell by >=25%" claim, so it is reported as a
1038
+ // measured non-improvement rather than a divide-by-zero success.
1039
+ if (baselineRate > 0) {
1040
+ reduction = (baselineRate - activeRate) / baselineRate;
1041
+ } else {
1042
+ reduction = 0;
1043
+ }
1044
+ success = baselineRate > 0 && reduction >= minReduction;
1045
+ status = success ? 'success' : 'no-improvement';
1046
+ }
1047
+ results.push({
1048
+ project,
1049
+ area,
1050
+ minimumSample,
1051
+ minReduction,
1052
+ baseline: baseline
1053
+ ? { createdFindings: baseline.createdFindings, repeatedFindings: baseline.repeatedFindings, rate: baselineRate, sampleSufficient: baselineSufficient }
1054
+ : { createdFindings: 0, repeatedFindings: 0, rate: null, sampleSufficient: false },
1055
+ active: active
1056
+ ? { createdFindings: active.createdFindings, repeatedFindings: active.repeatedFindings, rate: activeRate, sampleSufficient: activeSufficient }
1057
+ : { createdFindings: 0, repeatedFindings: 0, rate: null, sampleSufficient: false },
1058
+ reduction,
1059
+ success,
1060
+ status,
1061
+ });
1062
+ }
1063
+ results.sort((a, b) => (a.project === b.project ? a.area.localeCompare(b.area) : a.project.localeCompare(b.project)));
1064
+ return results;
1065
+ }
1066
+
1067
+ // Distinct valid findings backing a review-learning lesson (P-192d3660).
1068
+ // A single lesson record owns exactly one finding key (its source_ref), but the
1069
+ // same generalized rule can recur across multiple PRs/findings. Those
1070
+ // recurrences are captured as `repeated-finding` lifecycle events that reference
1071
+ // the original lesson through `metadata.priorLessonId`. Promotion eligibility
1072
+ // requires at least two distinct valid findings, so we union:
1073
+ // 1. the lesson's own source_ref finding key,
1074
+ // 2. finding keys of every lifecycle event stamped with this memory id, and
1075
+ // 3. finding keys of every `repeated-finding` event pointing back at it.
1076
+ // The related memory ids (prior + newer lessons in the same cluster) are
1077
+ // returned so the queued work item can cite stable originating memory ids.
1078
+ function getReviewLearningFindingSupport(memoryId) {
1079
+ const id = String(memoryId || '').trim();
1080
+ const empty = { memoryId: id, findingKeys: [], distinctFindings: 0, repeatedOccurrences: 0, memoryIds: id ? [id] : [] };
1081
+ if (!id) return empty;
1082
+ const db = getDb();
1083
+ const lesson = db.prepare('SELECT source_ref, metadata FROM memory_records WHERE id=?').get(id);
1084
+ if (!lesson) return empty;
1085
+ const findingKeys = new Set();
1086
+ const memoryIds = new Set([id]);
1087
+ if (lesson.source_ref) findingKeys.add(lesson.source_ref);
1088
+ const priorOfLesson = _parseJson(lesson.metadata, {})?.priorLessonId;
1089
+ if (priorOfLesson) memoryIds.add(String(priorOfLesson));
1090
+
1091
+ const direct = db.prepare(`
1092
+ SELECT finding_key, metadata FROM memory_lifecycle_events
1093
+ WHERE memory_id=? AND finding_key IS NOT NULL AND finding_key<>''
1094
+ `).all(id);
1095
+ for (const row of direct) {
1096
+ findingKeys.add(row.finding_key);
1097
+ const prior = _parseJson(row.metadata, {})?.priorLessonId;
1098
+ if (prior) memoryIds.add(String(prior));
1099
+ }
1100
+
1101
+ const repeated = db.prepare(`
1102
+ SELECT finding_key, memory_id FROM memory_lifecycle_events
1103
+ WHERE event_type='repeated-finding'
1104
+ AND json_extract(metadata,'$.priorLessonId')=?
1105
+ AND finding_key IS NOT NULL AND finding_key<>''
1106
+ `).all(id);
1107
+ for (const row of repeated) {
1108
+ findingKeys.add(row.finding_key);
1109
+ if (row.memory_id) memoryIds.add(String(row.memory_id));
1110
+ }
1111
+
1112
+ return {
1113
+ memoryId: id,
1114
+ findingKeys: [...findingKeys].sort(),
1115
+ distinctFindings: findingKeys.size,
1116
+ repeatedOccurrences: repeated.length,
1117
+ memoryIds: [...memoryIds].sort(),
1118
+ };
1119
+ }
1120
+
355
1121
  module.exports = {
356
1122
  MEMORY_TYPES,
357
1123
  SCOPE_TYPES,
358
1124
  STATUSES,
1125
+ ACTIONS,
359
1126
  upsertMemoryRecord,
360
1127
  getMemoryRecord,
361
1128
  updateMemoryRecordState,
@@ -364,6 +1131,22 @@ module.exports = {
364
1131
  transitionMemoryRecord,
365
1132
  supersedeMatchingRecords,
366
1133
  recordRetrievalRun,
1134
+ getLatestRetrievalTrace,
1135
+ listRetrievalTraces,
367
1136
  getRetrievalStats,
1137
+ upsertReviewLearningCandidate,
1138
+ upsertReviewLearningLesson,
1139
+ listReviewLearningCandidates,
1140
+ listActiveReviewLearnings,
1141
+ transitionReviewLearningCandidate,
1142
+ retractReviewLearningLesson,
1143
+ recordMemoryLifecycleEvent,
1144
+ listMemoryLifecycleEvents,
1145
+ pruneMemoryLifecycleEvents,
1146
+ expireStaleReviewLearningCandidates,
1147
+ getReviewLearningLifecycleSummary,
1148
+ getReviewLearningRolloutAggregates,
1149
+ getReviewLearningRolloutComparison,
1150
+ getReviewLearningFindingSupport,
368
1151
  _matchQuery,
369
1152
  };