@yemi33/minions 0.1.2423 → 0.1.2425

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 (184) 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 +6 -4
  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/internal-install.md +190 -0
  55. package/docs/kb-pr3223-cascade-archiving.md +16 -0
  56. package/docs/kb-pr696-merge-conflict-docs.md +23 -0
  57. package/docs/kb-sweep.md +2 -2
  58. package/docs/keep-processes.md +8 -1
  59. package/docs/live-checkout-mode.md +12 -7
  60. package/docs/managed-spawn.md +21 -3
  61. package/docs/named-agents.md +3 -2
  62. package/docs/plan-lifecycle.md +32 -32
  63. package/docs/pr-auto-fix-dispatch.md +3 -3
  64. package/docs/pr-review-fix-loop.md +6 -6
  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/03-plan-driven-feature.md +3 -3
  82. package/docs/tutorials/04-runtimes-and-harness.md +18 -21
  83. package/docs/tutorials/08-operations-and-recovery.md +3 -2
  84. package/docs/tutorials/README.md +4 -3
  85. package/docs/visual-evidence-ci.md +103 -0
  86. package/docs/watches.md +35 -30
  87. package/docs/worktree-lifecycle.md +151 -13
  88. package/engine/acp-transport.js +1 -0
  89. package/engine/ado.js +80 -33
  90. package/engine/agent-api-validation.js +571 -0
  91. package/engine/agent-worker-pool.js +7 -6
  92. package/engine/api-contracts/agent-content.js +761 -0
  93. package/engine/api-contracts/cc-ops.js +219 -0
  94. package/engine/api-contracts/config-runtime.js +419 -0
  95. package/engine/api-contracts/core.js +138 -0
  96. package/engine/api-contracts/index.js +506 -0
  97. package/engine/api-contracts/orchestration.js +602 -0
  98. package/engine/api-contracts/pull-requests.js +323 -0
  99. package/engine/api-contracts/qa-process.js +764 -0
  100. package/engine/api-contracts/work-plan-prd.js +494 -0
  101. package/engine/api-validation.js +543 -0
  102. package/engine/cc-api-validation.js +586 -0
  103. package/engine/cc-worker-pool.js +9 -1
  104. package/engine/cleanup.js +393 -271
  105. package/engine/cli.js +65 -7
  106. package/engine/comment-classifier.js +70 -5
  107. package/engine/consolidation.js +123 -5
  108. package/engine/content-api-validation.js +606 -0
  109. package/engine/create-pr-worktree.js +22 -1
  110. package/engine/db/migrations/027-review-learning-lifecycle.js +162 -0
  111. package/engine/dispatch.js +33 -0
  112. package/engine/distribution.js +189 -0
  113. package/engine/execution-model.js +59 -0
  114. package/engine/features.js +12 -0
  115. package/engine/github.js +67 -25
  116. package/engine/harness-context.js +313 -0
  117. package/engine/inbox-store.js +1 -1
  118. package/engine/kb-sweep.js +161 -38
  119. package/engine/keep-process-sweep.js +43 -3
  120. package/engine/lifecycle.js +1066 -165
  121. package/engine/llm.js +4 -0
  122. package/engine/managed-spawn.js +463 -48
  123. package/engine/meeting.js +197 -34
  124. package/engine/memory-retrieval.js +280 -0
  125. package/engine/memory-store.js +821 -38
  126. package/engine/model-discovery.js +17 -3
  127. package/engine/pipeline.js +802 -14
  128. package/engine/plan-prd-validation.js +745 -0
  129. package/engine/playbook.js +206 -17
  130. package/engine/pooled-agent-process.js +6 -1
  131. package/engine/pr-action.js +1 -1
  132. package/engine/pr-issue-validation.js +653 -0
  133. package/engine/pr-resolve.js +75 -13
  134. package/engine/prd-store.js +39 -6
  135. package/engine/preflight.js +7 -2
  136. package/engine/process-utils.js +79 -31
  137. package/engine/projects.js +198 -12
  138. package/engine/promotion.js +371 -0
  139. package/engine/qa-from-prd.js +3 -0
  140. package/engine/qa-process-validation.js +572 -0
  141. package/engine/qa-runbooks.js +41 -37
  142. package/engine/qa-runners/maestro.js +11 -7
  143. package/engine/qa-runners/playwright.js +37 -14
  144. package/engine/qa-runners.js +9 -10
  145. package/engine/qa-runs.js +124 -11
  146. package/engine/qa-sessions.js +139 -40
  147. package/engine/quarantine-refs.js +81 -11
  148. package/engine/queries.js +163 -9
  149. package/engine/review-learning-backfill.js +482 -0
  150. package/engine/review-learning.js +1236 -0
  151. package/engine/runtimes/claude.js +60 -2
  152. package/engine/runtimes/codex.js +57 -16
  153. package/engine/runtimes/contract.js +21 -0
  154. package/engine/runtimes/copilot.js +51 -3
  155. package/engine/runtimes/index.js +1 -0
  156. package/engine/scheduler.js +201 -32
  157. package/engine/settings-validation.js +909 -0
  158. package/engine/shared.js +705 -208
  159. package/engine/spawn-agent.js +48 -21
  160. package/engine/steering-constraints.js +31 -0
  161. package/engine/steering-store.js +10 -2
  162. package/engine/steering.js +14 -2
  163. package/engine/timeout.js +404 -111
  164. package/engine/untrusted-fence.js +24 -10
  165. package/engine/watch-actions.js +7 -2
  166. package/engine/watches.js +353 -51
  167. package/engine/work-item-validation.js +561 -0
  168. package/engine/work-items-store.js +61 -1
  169. package/engine/worktree-gc.js +656 -37
  170. package/engine/worktree-preflight.js +154 -0
  171. package/engine.js +2068 -342
  172. package/package.json +1 -1
  173. package/playbooks/_pr-description-audit.md +88 -41
  174. package/playbooks/build-fix-complex.md +3 -3
  175. package/playbooks/docs.md +2 -2
  176. package/playbooks/fix.md +18 -18
  177. package/playbooks/implement.md +18 -18
  178. package/playbooks/qa-session-draft.md +10 -12
  179. package/playbooks/qa-session-execute.md +16 -12
  180. package/playbooks/qa-validate.md +8 -7
  181. package/playbooks/shared-rules.md +45 -0
  182. package/playbooks/test.md +4 -0
  183. package/prompts/cc-system.md +1 -1
  184. package/skills/check-self-authored-review-comment/SKILL.md +2 -2
package/engine/meeting.js CHANGED
@@ -7,6 +7,7 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
  const shared = require('./shared');
9
9
  const { safeJson, uid, log, ts, ENGINE_DEFAULTS, WORK_TYPE, DISPATCH_RESULT, MEETING_STATUS } = shared;
10
+ const apiValidation = require('./api-validation');
10
11
  const queries = require('./queries');
11
12
  const { getDispatch, getConfig } = queries;
12
13
  const { renderPlaybook } = require('./playbook');
@@ -21,14 +22,31 @@ const EMPTY_OUTPUT_PATTERNS = ['(no output)', '(no findings)', '(no response)'];
21
22
  // tests can redirect the meetings directory without patching module internals.
22
23
  const MEETINGS_DIR = path.join(shared.MINIONS_DIR, 'meetings');
23
24
  const MEETING_NOTE_ARTIFACT_ROOT = path.join(shared.MINIONS_DIR, 'notes', 'inbox');
24
- const TERMINAL_MEETING_STATUSES = new Set(['completed', 'archived']);
25
+ const MEETING_LIMITS = Object.freeze({
26
+ maxTitleLength: 200,
27
+ maxAgendaLength: 20000,
28
+ maxNoteLength: 4000,
29
+ maxParticipants: 50,
30
+ maxIdentifierLength: 128,
31
+ });
32
+ const TERMINAL_MEETING_STATUSES = new Set([
33
+ MEETING_STATUS.COMPLETED,
34
+ MEETING_STATUS.ARCHIVED,
35
+ ]);
25
36
  const ROUND_STATUS_BY_NAME = {
26
- investigate: 'investigating',
27
- debate: 'debating',
28
- conclude: 'concluding',
37
+ investigate: MEETING_STATUS.INVESTIGATING,
38
+ debate: MEETING_STATUS.DEBATING,
39
+ conclude: MEETING_STATUS.CONCLUDING,
29
40
  };
30
41
  const ROUND_NUMBER_BY_NAME = { investigate: 1, debate: 2, conclude: 3 };
31
42
  const ACTIVE_MEETING_STATUSES = new Set(Object.values(ROUND_STATUS_BY_NAME));
43
+ const MEETING_ACTION_STATUSES = Object.freeze({
44
+ note: ACTIVE_MEETING_STATUSES,
45
+ advance: ACTIVE_MEETING_STATUSES,
46
+ end: ACTIVE_MEETING_STATUSES,
47
+ archive: new Set([MEETING_STATUS.COMPLETED]),
48
+ unarchive: new Set([MEETING_STATUS.ARCHIVED]),
49
+ });
32
50
 
33
51
  function isTerminalMeetingStatus(status) {
34
52
  return TERMINAL_MEETING_STATUSES.has(String(status || '').toLowerCase());
@@ -50,6 +68,141 @@ function expectedMeetingStatusForRound(roundName) {
50
68
  return ROUND_STATUS_BY_NAME[String(roundName || '').toLowerCase()] || null;
51
69
  }
52
70
 
71
+ function validateMeetingId(value) {
72
+ return apiValidation.validateIdentifier(value, {
73
+ field: 'id',
74
+ path: 'id',
75
+ trim: true,
76
+ maxLength: MEETING_LIMITS.maxIdentifierLength,
77
+ pattern: /^MTG-[A-Za-z0-9_-]+$/,
78
+ });
79
+ }
80
+
81
+ function validateMeetingCreateInput(input, config, options = {}) {
82
+ apiValidation.validatePlainObject(input, { field: 'body', path: 'body' });
83
+ const title = apiValidation.validateString(input.title, {
84
+ field: 'title',
85
+ path: 'title',
86
+ trim: true,
87
+ minLength: 1,
88
+ maxLength: MEETING_LIMITS.maxTitleLength,
89
+ });
90
+ const agenda = apiValidation.validateString(input.agenda, {
91
+ field: 'agenda',
92
+ path: 'agenda',
93
+ trim: true,
94
+ minLength: 1,
95
+ maxLength: MEETING_LIMITS.maxAgendaLength,
96
+ });
97
+ const minimumParticipants = Number.isInteger(options.minParticipants)
98
+ ? options.minParticipants
99
+ : 0;
100
+ const sourceParticipants = input.participants === undefined ? [] : input.participants;
101
+ apiValidation.validateArray(sourceParticipants, {
102
+ field: 'participants',
103
+ path: 'participants',
104
+ minLength: minimumParticipants,
105
+ maxLength: MEETING_LIMITS.maxParticipants,
106
+ });
107
+
108
+ const participants = [];
109
+ const seen = new Set();
110
+ const configuredAgents = config && typeof config === 'object'
111
+ ? Object.keys(config.agents || {})
112
+ : null;
113
+ sourceParticipants.forEach((participant, index) => {
114
+ const pathParts = ['participants', index];
115
+ const id = apiValidation.validateIdentifier(participant, {
116
+ field: 'participants',
117
+ path: pathParts,
118
+ trim: true,
119
+ maxLength: MEETING_LIMITS.maxIdentifierLength,
120
+ });
121
+ if (seen.has(id)) {
122
+ throw new apiValidation.ApiInputError(`participants[${index}] duplicates participant "${id}"`, {
123
+ code: 'duplicate-participant',
124
+ field: 'participants',
125
+ path: pathParts,
126
+ rejectedValue: participant,
127
+ });
128
+ }
129
+ if (configuredAgents) {
130
+ if (!configuredAgents.includes(id)) {
131
+ throw new apiValidation.ApiInputError(
132
+ `participants[${index}] must name a configured agent`,
133
+ {
134
+ code: 'invalid-enum',
135
+ field: 'participants',
136
+ path: pathParts,
137
+ rejectedValue: participant,
138
+ allowedValues: configuredAgents,
139
+ },
140
+ );
141
+ }
142
+ }
143
+ seen.add(id);
144
+ participants.push(id);
145
+ });
146
+ return { title, agenda, participants };
147
+ }
148
+
149
+ function validateMeetingNote(note) {
150
+ return apiValidation.validateString(note, {
151
+ field: 'note',
152
+ path: 'note',
153
+ trim: true,
154
+ minLength: 1,
155
+ maxLength: MEETING_LIMITS.maxNoteLength,
156
+ });
157
+ }
158
+
159
+ function assertMeetingLifecycle(meeting, action) {
160
+ const allowedStatuses = MEETING_ACTION_STATUSES[action];
161
+ if (!allowedStatuses) throw new TypeError(`Unknown meeting lifecycle action: ${action}`);
162
+ if (!allowedStatuses.has(meeting.status)) {
163
+ throw new apiValidation.ApiInputError(
164
+ `Meeting ${meeting.id} cannot ${action} while status is ${meeting.status || '(missing)'}`,
165
+ {
166
+ code: 'meeting-state-conflict',
167
+ statusCode: 409,
168
+ field: 'status',
169
+ path: 'status',
170
+ rejectedValue: meeting.status,
171
+ allowedValues: [...allowedStatuses],
172
+ },
173
+ );
174
+ }
175
+ return meeting;
176
+ }
177
+
178
+ function setMeetingRound(meeting, roundName) {
179
+ const status = expectedMeetingStatusForRound(roundName);
180
+ const round = ROUND_NUMBER_BY_NAME[String(roundName || '').toLowerCase()];
181
+ if (!status || !round) throw new TypeError(`Unknown meeting round: ${roundName}`);
182
+ meeting.status = status;
183
+ meeting.round = round;
184
+ meeting.roundStartedAt = ts();
185
+ return meeting;
186
+ }
187
+
188
+ function completeMeeting(meeting) {
189
+ meeting.status = MEETING_STATUS.COMPLETED;
190
+ meeting.completedAt = ts();
191
+ return meeting;
192
+ }
193
+
194
+ function advanceMeetingState(meeting) {
195
+ if (meeting.status === MEETING_STATUS.INVESTIGATING) return setMeetingRound(meeting, 'debate');
196
+ if (meeting.status === MEETING_STATUS.DEBATING) return setMeetingRound(meeting, 'conclude');
197
+ if (meeting.status === MEETING_STATUS.CONCLUDING) {
198
+ completeMeeting(meeting);
199
+ meeting.roundStartedAt = ts();
200
+ return meeting;
201
+ }
202
+ assertMeetingLifecycle(meeting, 'advance');
203
+ return meeting;
204
+ }
205
+
53
206
  function roundKeyFor(roundName, round) {
54
207
  const numeric = Number(round);
55
208
  if (Number.isFinite(numeric) && numeric > 0) return String(numeric);
@@ -106,17 +259,13 @@ function buildFailedMeetingConclusion(meeting, agents, reason) {
106
259
  function advanceMeetingIfRoundComplete(meeting, roundName, meetingId, config = null, pendingInboxWrites = null) {
107
260
  if (roundName === 'investigate') {
108
261
  if (!allParticipantsFinishedRound(meeting, roundName, meeting.round)) return false;
109
- meeting.status = 'debating';
110
- meeting.round = 2;
111
- meeting.roundStartedAt = ts();
262
+ setMeetingRound(meeting, 'debate');
112
263
  log('info', `Meeting ${meetingId}: all findings finished — advancing to debate`);
113
264
  return true;
114
265
  }
115
266
  if (roundName === 'debate') {
116
267
  if (!allParticipantsFinishedRound(meeting, roundName, meeting.round)) return false;
117
- meeting.status = 'concluding';
118
- meeting.round = 3;
119
- meeting.roundStartedAt = ts();
268
+ setMeetingRound(meeting, 'conclude');
120
269
  log('info', `Meeting ${meetingId}: all debate responses finished — advancing to conclusion`);
121
270
  return true;
122
271
  }
@@ -125,8 +274,7 @@ function advanceMeetingIfRoundComplete(meeting, roundName, meetingId, config = n
125
274
  const autoConclusion = buildFailedMeetingConclusion(meeting, agents);
126
275
  meeting.conclusion = { content: autoConclusion, agent: 'system', submittedAt: ts() };
127
276
  meeting.transcript.push({ round: meeting.round, agent: 'system', type: 'conclusion', content: autoConclusion, at: ts() });
128
- meeting.status = 'completed';
129
- meeting.completedAt = ts();
277
+ completeMeeting(meeting);
130
278
  // Defer inbox write until AFTER the mutateMeeting lock releases —
131
279
  // writeMeetingTranscriptToInbox hits the filesystem (slug dedup, write)
132
280
  // and must not block other writers. Matches the happy-path pattern at
@@ -489,13 +637,16 @@ function saveMeeting(meeting) {
489
637
  return mutateMeeting(meeting.id, () => meeting);
490
638
  }
491
639
 
492
- function createMeeting({ title, agenda, participants }) {
640
+ function createMeeting(input, config, options) {
641
+ const validated = validateMeetingCreateInput(input, config, options);
493
642
  const id = 'MTG-' + uid();
494
643
  const meeting = {
495
- id, title, agenda,
496
- status: 'investigating',
644
+ id,
645
+ title: validated.title,
646
+ agenda: validated.agenda,
647
+ status: MEETING_STATUS.INVESTIGATING,
497
648
  round: 1,
498
- participants: participants || [],
649
+ participants: validated.participants,
499
650
  createdBy: 'human',
500
651
  createdAt: ts(),
501
652
  roundStartedAt: ts(),
@@ -739,8 +890,7 @@ function collectMeetingFindings(meetingId, agentId, roundName, output, structure
739
890
  } else if (roundName === 'conclude') {
740
891
  meeting.conclusion = { content, agent: agentId, submittedAt: ts() };
741
892
  meeting.transcript.push({ round: meeting.round, agent: agentId, type: 'conclusion', content, at: ts() });
742
- meeting.status = 'completed';
743
- meeting.completedAt = ts();
893
+ completeMeeting(meeting);
744
894
  // Defer inbox write until AFTER the lock releases — writeToInbox hits
745
895
  // the filesystem (slug dedup, write) and must not block other writers.
746
896
  concludedMeeting = meeting;
@@ -767,10 +917,15 @@ function collectMeetingFindings(meetingId, agentId, roundName, output, structure
767
917
  }
768
918
 
769
919
  function addMeetingNote(meetingId, note) {
920
+ const normalizedNote = validateMeetingNote(note);
921
+ const existing = getMeeting(meetingId);
922
+ if (!existing) return null;
923
+ assertMeetingLifecycle(existing, 'note');
770
924
  return mutateMeeting(meetingId, (meeting) => {
771
925
  if (!meeting) return null;
772
- meeting.humanNotes.push(note);
773
- meeting.transcript.push({ round: meeting.round, agent: 'human', type: 'note', content: note, at: ts() });
926
+ assertMeetingLifecycle(meeting, 'note');
927
+ meeting.humanNotes.push(normalizedNote);
928
+ meeting.transcript.push({ round: meeting.round, agent: 'human', type: 'note', content: normalizedNote, at: ts() });
774
929
  return meeting;
775
930
  });
776
931
  }
@@ -844,7 +999,8 @@ function advanceMeetingRound(meetingId) {
844
999
  // already terminal. The authoritative status check still runs INSIDE the
845
1000
  // lock below.
846
1001
  const existing = getMeeting(meetingId);
847
- if (!existing || existing.status === MEETING_STATUS.COMPLETED || existing.status === MEETING_STATUS.ARCHIVED) return null;
1002
+ if (!existing) return null;
1003
+ assertMeetingLifecycle(existing, 'advance');
848
1004
 
849
1005
  // CRITICAL: kill BEFORE acquiring the meeting lock. _killMeetingDispatches
850
1006
  // mutates dispatch state and shells out to kill processes — never
@@ -853,13 +1009,9 @@ function advanceMeetingRound(meetingId) {
853
1009
  _killMeetingDispatches(meetingId);
854
1010
 
855
1011
  return mutateMeeting(meetingId, (meeting) => {
856
- if (!meeting || meeting.status === MEETING_STATUS.COMPLETED || meeting.status === MEETING_STATUS.ARCHIVED) return null;
857
- if (meeting.status === 'investigating') { meeting.status = 'debating'; meeting.round = 2; }
858
- else if (meeting.status === 'debating') { meeting.status = 'concluding'; meeting.round = 3; }
859
- else if (meeting.status === 'concluding') { meeting.status = 'completed'; meeting.completedAt = ts(); }
860
- else return meeting; // unknown active status — no state change, but report current
861
- meeting.roundStartedAt = ts();
862
- return meeting;
1012
+ if (!meeting) return null;
1013
+ assertMeetingLifecycle(meeting, 'advance');
1014
+ return advanceMeetingState(meeting);
863
1015
  });
864
1016
  }
865
1017
 
@@ -868,19 +1020,23 @@ function endMeeting(meetingId) {
868
1020
  // teardown / process kills never run inside our lock callback.
869
1021
  const existing = getMeeting(meetingId);
870
1022
  if (!existing) return null;
1023
+ assertMeetingLifecycle(existing, 'end');
871
1024
  _killMeetingDispatches(meetingId);
872
1025
 
873
1026
  return mutateMeeting(meetingId, (meeting) => {
874
1027
  if (!meeting) return null;
875
- meeting.status = MEETING_STATUS.COMPLETED;
876
- meeting.completedAt = ts();
877
- return meeting;
1028
+ assertMeetingLifecycle(meeting, 'end');
1029
+ return completeMeeting(meeting);
878
1030
  });
879
1031
  }
880
1032
 
881
1033
  function archiveMeeting(id) {
1034
+ const existing = getMeeting(id);
1035
+ if (!existing) return null;
1036
+ assertMeetingLifecycle(existing, 'archive');
882
1037
  return mutateMeeting(id, (meeting) => {
883
1038
  if (!meeting) return null;
1039
+ assertMeetingLifecycle(meeting, 'archive');
884
1040
  meeting.status = MEETING_STATUS.ARCHIVED;
885
1041
  meeting.archivedAt = ts();
886
1042
  return meeting;
@@ -888,18 +1044,22 @@ function archiveMeeting(id) {
888
1044
  }
889
1045
 
890
1046
  function unarchiveMeeting(id) {
1047
+ const existing = getMeeting(id);
1048
+ if (!existing) return null;
1049
+ assertMeetingLifecycle(existing, 'unarchive');
891
1050
  return mutateMeeting(id, (meeting) => {
892
- if (!meeting || meeting.status !== 'archived') return null;
893
- meeting.status = 'completed';
1051
+ if (!meeting) return null;
1052
+ assertMeetingLifecycle(meeting, 'unarchive');
1053
+ meeting.status = MEETING_STATUS.COMPLETED;
894
1054
  delete meeting.archivedAt;
895
1055
  return meeting;
896
1056
  });
897
1057
  }
898
1058
 
899
1059
  function deleteMeeting(id) {
1060
+ if (!getMeeting(id)) return false;
900
1061
  _killMeetingDispatches(id);
901
1062
  const filePath = path.join(MEETINGS_DIR, id + '.json');
902
- if (!fs.existsSync(filePath)) return false;
903
1063
  try {
904
1064
  fs.unlinkSync(filePath);
905
1065
  } catch (e) {
@@ -1042,10 +1202,13 @@ function checkMeetingTimeouts(config) {
1042
1202
  }
1043
1203
  }
1044
1204
  module.exports = {
1205
+ MEETING_LIMITS,
1045
1206
  MEETINGS_DIR, getMeetings, summarizeMeetingsForStatus, getMeeting, saveMeeting, mutateMeeting, createMeeting,
1046
1207
  invalidateMeetingsCache,
1047
1208
  discoverMeetingWork, collectMeetingFindings, checkMeetingTimeouts,
1048
1209
  addMeetingNote, advanceMeetingRound, endMeeting, archiveMeeting, unarchiveMeeting, deleteMeeting,
1210
+ validateMeetingId, validateMeetingCreateInput, validateMeetingNote,
1211
+ assertMeetingLifecycle, setMeetingRound, completeMeeting, advanceMeetingState,
1049
1212
  EMPTY_OUTPUT_PATTERNS,
1050
1213
  // exported for testing — engine code MUST go through
1051
1214
  // getMeetings/discoverMeetingWork/collectMeetingFindings/checkMeetingTimeouts,
@@ -1,10 +1,12 @@
1
1
  // engine/memory-retrieval.js — deterministic task-aware memory selection.
2
2
 
3
3
  const memoryStore = require('./memory-store');
4
+ const reviewLearning = require('./review-learning');
4
5
 
5
6
  const DEFAULT_TOP_K = 8;
6
7
  const DEFAULT_MAX_BYTES = 8 * 1024;
7
8
  const DEFAULT_CANDIDATES = 50;
9
+ const REVIEW_LEARNING_CANDIDATE_LIMIT = 200;
8
10
 
9
11
  function buildTaskMemoryQuery(context = {}) {
10
12
  const references = Array.isArray(context.references)
@@ -152,6 +154,281 @@ function retrieveRelevantMemory(context = {}, options = {}) {
152
154
  };
153
155
  }
154
156
 
157
+ // ── Reserved deterministic review-lesson recall ─────────────────────────────
158
+ // A dedicated project + audience recall path that never depends on an FTS hit:
159
+ // active review-learning lessons are listed straight from the project-scoped
160
+ // index (memory-store#listActiveReviewLearnings) and ranked so that exact
161
+ // affected-file, symbol, and tag overlap wins before lexical relevance,
162
+ // confidence, and recency. The pack is byte-bounded independently of the
163
+ // generic Relevant Memory budget and is fenced separately at the injection
164
+ // site. Review-learning records are globally excluded from generic recall
165
+ // (memory-store#searchMemoryRecords), so this is their only recall surface.
166
+
167
+ const REVIEW_FILE_PATH_RE = /(?:[\w.$@+-]+\/)+[\w.$@+-]+\.[a-z0-9]{1,8}/gi;
168
+ const REVIEW_BACKTICK_RE = /`([^`\n]{1,120})`/g;
169
+ const REVIEW_CAMEL_RE = /\b[a-z][a-z0-9]*(?:[A-Z][a-z0-9]*)+\b/g;
170
+ const REVIEW_CALL_RE = /\b([a-z_$][a-z0-9_$]{1,63})\s*\(/gi;
171
+ const REVIEW_LEX_STOP = new Set([
172
+ 'the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'when', 'then',
173
+ 'have', 'has', 'was', 'are', 'not', 'but', 'you', 'your', 'all', 'any',
174
+ 'use', 'using', 'via', 'per', 'fix', 'review', 'implement', 'task', 'work',
175
+ ]);
176
+
177
+ function _asArray(value) {
178
+ if (value == null || value === '') return [];
179
+ return Array.isArray(value) ? value : [value];
180
+ }
181
+
182
+ function _reviewContextText(context) {
183
+ const references = _asArray(context.references).map(ref => {
184
+ if (typeof ref === 'string') return ref;
185
+ if (!ref || typeof ref !== 'object') return '';
186
+ return [ref.path, ref.url, ref.title, ref.type].filter(Boolean).join(' ');
187
+ }).join(' ');
188
+ return [
189
+ context.title,
190
+ context.description,
191
+ context.type,
192
+ context.failureClass,
193
+ context.sourcePlan,
194
+ references,
195
+ context.prTitle,
196
+ context.prBranch,
197
+ ].filter(Boolean).join('\n');
198
+ }
199
+
200
+ function _reviewFilePaths(context, text) {
201
+ const raw = [];
202
+ for (const value of [..._asArray(context.files), ..._asArray(context.changedFiles)]) raw.push(value);
203
+ for (const ref of _asArray(context.references)) {
204
+ if (ref && typeof ref === 'object' && ref.path) raw.push(ref.path);
205
+ }
206
+ for (const match of text.matchAll(REVIEW_FILE_PATH_RE)) raw.push(match[0]);
207
+ return raw;
208
+ }
209
+
210
+ function _reviewSymbols(context, text) {
211
+ const raw = [..._asArray(context.symbols)];
212
+ for (const match of text.matchAll(REVIEW_BACKTICK_RE)) {
213
+ const inner = match[1].trim();
214
+ if (/^[\w.$/<>:-]+$/.test(inner)) raw.push(inner);
215
+ }
216
+ for (const match of text.matchAll(REVIEW_CAMEL_RE)) raw.push(match[0]);
217
+ for (const match of text.matchAll(REVIEW_CALL_RE)) raw.push(match[1]);
218
+ return raw;
219
+ }
220
+
221
+ function _reviewTags(context) {
222
+ const raw = [..._asArray(context.tags)];
223
+ if (context.type) raw.push(context.type);
224
+ return raw;
225
+ }
226
+
227
+ // Derive normalized affected-file, symbol, and tag hints for any project-bound
228
+ // work item or PR dispatch. Normalization mirrors the capture side exactly
229
+ // (review-learning#normalizeReviewFacets) so hint ↔ lesson facet comparison is
230
+ // deterministic and case-insensitive.
231
+ function deriveReviewFacetHints(context = {}) {
232
+ const text = _reviewContextText(context);
233
+ return reviewLearning.normalizeReviewFacets({
234
+ files: _reviewFilePaths(context, text),
235
+ symbols: _reviewSymbols(context, text),
236
+ tags: _reviewTags(context),
237
+ });
238
+ }
239
+
240
+ function _reviewLexTokens(text) {
241
+ const tokens = String(text || '').toLowerCase().match(/[a-z0-9][a-z0-9_.-]{2,63}/g) || [];
242
+ return new Set(tokens.filter(token => !REVIEW_LEX_STOP.has(token)));
243
+ }
244
+
245
+ function _overlapCount(a, b) {
246
+ if (!a.length || !b.length) return 0;
247
+ const other = new Set(b);
248
+ let count = 0;
249
+ for (const value of a) if (other.has(value)) count += 1;
250
+ return count;
251
+ }
252
+
253
+ function _lessonFacets(record) {
254
+ try {
255
+ return reviewLearning.normalizeReviewFacets(record.metadata || {});
256
+ } catch {
257
+ return { files: [], symbols: [], tags: [], area: 'general' };
258
+ }
259
+ }
260
+
261
+ // Rank applicable lessons: exact symbol, then file, then tag overlap dominate;
262
+ // lexical relevance, confidence, and recency only break ties. A lesson is
263
+ // applicable when it shares at least one exact facet OR any lexical token, so a
264
+ // lexically unrelated task touching the same file/symbol still recalls it.
265
+ function rankApplicableReviewLearnings(records, hints, options = {}) {
266
+ const hintTokens = options.hintTokens || _reviewLexTokens(options.queryText || '');
267
+ const scored = (Array.isArray(records) ? records : []).map(record => {
268
+ const facets = _lessonFacets(record);
269
+ const symbolOverlap = _overlapCount(hints.symbols, facets.symbols);
270
+ const fileOverlap = _overlapCount(hints.files, facets.files);
271
+ const tagOverlap = _overlapCount(hints.tags, facets.tags);
272
+ const lessonTokens = _reviewLexTokens(
273
+ `${record.title || ''}\n${record.body || ''}\n${(record.tags || []).join(' ')}`,
274
+ );
275
+ let lexical = 0;
276
+ for (const token of hintTokens) if (lessonTokens.has(token)) lexical += 1;
277
+ const confidence = Math.max(0, Math.min(1, Number(record.confidence) || 0));
278
+ const reasons = [];
279
+ if (symbolOverlap) reasons.push('exact-symbol');
280
+ if (fileOverlap) reasons.push('exact-file');
281
+ if (tagOverlap) reasons.push('exact-tag');
282
+ if (lexical) reasons.push('lexical');
283
+ const applicable = (symbolOverlap + fileOverlap + tagOverlap) > 0 || lexical > 0;
284
+ if (!applicable) reasons.push('not-applicable');
285
+ const score =
286
+ (symbolOverlap * 1000000) +
287
+ (fileOverlap * 10000) +
288
+ (tagOverlap * 100) +
289
+ Math.min(50, lexical) +
290
+ confidence;
291
+ return { record, facets, symbolOverlap, fileOverlap, tagOverlap, lexical, confidence, applicable, score, reasons };
292
+ });
293
+ return scored.sort((a, b) =>
294
+ b.symbolOverlap - a.symbolOverlap ||
295
+ b.fileOverlap - a.fileOverlap ||
296
+ b.tagOverlap - a.tagOverlap ||
297
+ b.lexical - a.lexical ||
298
+ b.confidence - a.confidence ||
299
+ Number(b.record.updatedAt || 0) - Number(a.record.updatedAt || 0) ||
300
+ String(a.record.id).localeCompare(String(b.record.id)));
301
+ }
302
+
303
+ function _formatReviewLesson(entry, bodyBytes) {
304
+ const record = entry.record;
305
+ const meta = record.metadata || {};
306
+ const provenance = [
307
+ `id=${record.id}`,
308
+ `audience=${meta.audience || 'project-team'}`,
309
+ meta.disposition ? `disposition=${meta.disposition}` : '',
310
+ `area=${meta.area || 'general'}`,
311
+ entry.facets.files.length ? `files=${entry.facets.files.join(',')}` : '',
312
+ entry.facets.symbols.length ? `symbols=${entry.facets.symbols.join(',')}` : '',
313
+ entry.facets.tags.length ? `tags=${entry.facets.tags.join(',')}` : '',
314
+ `date=${new Date(record.validFrom || record.createdAt || Date.now()).toISOString().slice(0, 10)}`,
315
+ ].filter(Boolean).join(' | ');
316
+ const fullBody = String(record.body || '');
317
+ const body = _truncateUtf8(fullBody, bodyBytes);
318
+ const truncated = body.length < fullBody.length ? '\n\n_[excerpt truncated]_' : '';
319
+ return `### ${record.title}\n_${provenance}_\n\n${body}${truncated}`;
320
+ }
321
+
322
+ // Bounded, deterministic recall of applicable prior review lessons. Returns a
323
+ // separately-packable block capped at review-learning applicableTopK records /
324
+ // applicableBytes, plus a bounded decision trace (stable ids, scores, reason
325
+ // codes) suitable for retrieval telemetry.
326
+ function retrieveApplicableReviewLearnings(context = {}, options = {}) {
327
+ const started = process.hrtime.bigint();
328
+ const project = String(context.project || '').trim();
329
+ const durationOf = () => Number(process.hrtime.bigint() - started) / 1e6;
330
+ const base = {
331
+ project,
332
+ hints: { files: [], symbols: [], tags: [], area: 'general' },
333
+ candidates: [],
334
+ selected: [],
335
+ text: '',
336
+ bytes: 0,
337
+ decisionTrace: [],
338
+ selectedIds: [],
339
+ durationMs: 0,
340
+ };
341
+ // Only dispatches with a resolved project context are eligible. Projectless
342
+ // Command Center and unrelated central work get no review-learning section.
343
+ if (!project) return { ...base, durationMs: durationOf() };
344
+
345
+ const bounds = reviewLearning.REVIEW_LEARNING_BOUNDS;
346
+ const topK = Math.max(1, Math.min(bounds.applicableTopK, Number(options.topK) || bounds.applicableTopK));
347
+ const maxBytes = Math.max(512, Math.min(bounds.applicableBytes, Number(options.maxBytes) || bounds.applicableBytes));
348
+ const hints = deriveReviewFacetHints(context);
349
+ const agent = String(context.agent || '').trim().toLowerCase();
350
+
351
+ let lessons;
352
+ try {
353
+ lessons = memoryStore.listActiveReviewLearnings({
354
+ project,
355
+ limit: Number(options.candidateLimit) || REVIEW_LEARNING_CANDIDATE_LIMIT,
356
+ });
357
+ } catch {
358
+ return { ...base, hints, durationMs: durationOf() };
359
+ }
360
+
361
+ // Audience gate: project-team lessons reach any agent in the same project;
362
+ // author-only lessons require the matching author agent. Project scoping is
363
+ // already enforced by listActiveReviewLearnings, so names/paths never leak
364
+ // across projects.
365
+ const audienceFiltered = (lessons || []).filter(record => {
366
+ const audience = String(record.metadata?.audience || 'project-team').toLowerCase();
367
+ if (audience === 'author') {
368
+ return !!agent && String(record.metadata?.authorAgent || '').toLowerCase() === agent;
369
+ }
370
+ return true;
371
+ });
372
+
373
+ const hintTokens = _reviewLexTokens(_reviewContextText(context));
374
+ const ranked = rankApplicableReviewLearnings(audienceFiltered, hints, { hintTokens });
375
+
376
+ const selected = [];
377
+ const decisionTrace = [];
378
+ let used = 0;
379
+ let rank = 0;
380
+ for (const entry of ranked) {
381
+ const traceEntry = {
382
+ id: entry.record.id,
383
+ rank,
384
+ score: Number(entry.score.toFixed(4)),
385
+ selected: false,
386
+ reasons: entry.reasons.slice(),
387
+ };
388
+ rank += 1;
389
+ if (!entry.applicable) {
390
+ decisionTrace.push(traceEntry);
391
+ continue;
392
+ }
393
+ if (selected.length >= topK) {
394
+ traceEntry.reasons.push('record-cap');
395
+ decisionTrace.push(traceEntry);
396
+ continue;
397
+ }
398
+ const remaining = maxBytes - used;
399
+ if (remaining < 128) {
400
+ traceEntry.reasons.push('byte-cap');
401
+ decisionTrace.push(traceEntry);
402
+ continue;
403
+ }
404
+ const block = _formatReviewLesson(entry, Math.max(96, remaining - 96));
405
+ const separator = selected.length ? '\n\n---\n\n' : '';
406
+ const bytes = Buffer.byteLength(separator + block, 'utf8');
407
+ if (bytes > remaining) {
408
+ traceEntry.reasons.push('byte-cap');
409
+ decisionTrace.push(traceEntry);
410
+ continue;
411
+ }
412
+ used += bytes;
413
+ selected.push({ record: entry.record, block });
414
+ traceEntry.selected = true;
415
+ decisionTrace.push(traceEntry);
416
+ }
417
+
418
+ const text = selected.map(item => item.block).join('\n\n---\n\n');
419
+ return {
420
+ project,
421
+ hints,
422
+ candidates: ranked,
423
+ selected: selected.map(item => item.record),
424
+ text,
425
+ bytes: used,
426
+ decisionTrace,
427
+ selectedIds: selected.map(item => item.record.id),
428
+ durationMs: durationOf(),
429
+ };
430
+ }
431
+
155
432
  module.exports = {
156
433
  DEFAULT_TOP_K,
157
434
  DEFAULT_MAX_BYTES,
@@ -161,5 +438,8 @@ module.exports = {
161
438
  dedupeMemoryCandidates,
162
439
  packMemoryResults,
163
440
  retrieveRelevantMemory,
441
+ deriveReviewFacetHints,
442
+ rankApplicableReviewLearnings,
443
+ retrieveApplicableReviewLearnings,
164
444
  _truncateUtf8,
165
445
  };