@yemi33/minions 0.1.1869 → 0.1.1871

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1871 (2026-05-11)
4
+
5
+ ### Features
6
+ - per-agent memory file architecture (P-f1c5a8b6) (#2354)
7
+ - Implement pre-dispatch acceptance criteria validation gate (P-a2d6b9c7) (#2352)
8
+
9
+ ## 0.1.1870 (2026-05-11)
10
+
11
+ ### Fixes
12
+ - don't kill live agents on premature [process-exit] sentinel
13
+
3
14
  ## 0.1.1869 (2026-05-11)
4
15
 
5
16
  ### Other
@@ -15,6 +15,109 @@ const queries = require('./queries');
15
15
  const { getInboxFiles, getNotes, INBOX_DIR, ENGINE_DIR,
16
16
  NOTES_PATH, KNOWLEDGE_DIR, ARCHIVE_DIR } = queries;
17
17
 
18
+ // Per-agent memory files live under knowledge/agents/<agent>.md and are
19
+ // injected into individual agent prompts (in addition to the broadcast
20
+ // notes.md). See knowledge/agents/README.md for the convention.
21
+ const AGENT_MEMORY_DIR = path.join(KNOWLEDGE_DIR, 'agents');
22
+ // Cap per-agent file size; oldest sections are pruned at section boundaries.
23
+ const AGENT_MEMORY_BUDGET_BYTES = 25000;
24
+ // Match valid agent IDs (lowercase alphanumeric + hyphen, no path separators);
25
+ // excludes temp-* IDs which we filter separately.
26
+ const AGENT_ID_PATTERN = /^[a-z][a-z0-9-]{0,40}$/;
27
+
28
+ /**
29
+ * Extract the authoring agent for an inbox item.
30
+ * Prefers YAML frontmatter `agent:` field; falls back to filename prefix
31
+ * (`<agent>-...md`). Returns lowercase agent id or null.
32
+ */
33
+ function extractInboxAgent(item) {
34
+ const content = String(item?.content || '');
35
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
36
+ if (fmMatch) {
37
+ const agentLine = fmMatch[1].split('\n').find(l => /^agent:\s*/i.test(l));
38
+ if (agentLine) {
39
+ const val = agentLine.replace(/^agent:\s*/i, '').trim().toLowerCase();
40
+ if (val && AGENT_ID_PATTERN.test(val)) return val;
41
+ }
42
+ }
43
+ // Filename fallback: take the leading [a-z][a-z0-9]* segment up to the
44
+ // first hyphen (e.g. `dallas-task-1.md` → `dallas`). Hyphens inside the
45
+ // capture would over-match (`rebecca-pr-7-2026.md` → `rebecca-pr-7`).
46
+ const nameMatch = String(item?.name || '').match(/^([a-z][a-z0-9]{1,40})-/i);
47
+ if (nameMatch) {
48
+ const val = nameMatch[1].toLowerCase();
49
+ if (AGENT_ID_PATTERN.test(val)) return val;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * Append an inbox item to its author's personal memory file when the agent
56
+ * is a known team member (must be present in `knownAgents`) and not a
57
+ * temp-* id. Strict superset of broadcast consolidation — this never
58
+ * replaces the notes.md write; it's an additional per-agent personalization
59
+ * layer. Returns true on write, false on skip.
60
+ *
61
+ * `knownAgents` is required (a Set of lowercase agent IDs from
62
+ * `config.agents`). When omitted, per-agent routing is skipped entirely so
63
+ * we never create memory files for unverified IDs.
64
+ */
65
+ function appendToAgentMemory(item, knownAgents) {
66
+ const agent = extractInboxAgent(item);
67
+ if (!agent) return false;
68
+ if (agent.startsWith('temp-')) return false;
69
+ if (!knownAgents || !knownAgents.has(agent)) return false;
70
+
71
+ if (!fs.existsSync(AGENT_MEMORY_DIR)) {
72
+ try { fs.mkdirSync(AGENT_MEMORY_DIR, { recursive: true }); }
73
+ catch (err) { log('warn', `Failed to create agent memory dir: ${err.message}`); return false; }
74
+ }
75
+
76
+ const memPath = path.join(AGENT_MEMORY_DIR, `${agent}.md`);
77
+ const content = String(item.content || '').trim();
78
+ if (!content) return false;
79
+
80
+ const titleMatch = content.match(/^#\s+(.+)/m);
81
+ const title = titleMatch ? titleMatch[1].trim() : (item.name || 'untitled').replace(/\.md$/, '');
82
+ const entry = `\n\n---\n\n### ${dateStamp()}: ${title}\n_Source: \`notes/inbox/${item.name}\`_\n\n${content}\n`;
83
+
84
+ try {
85
+ shared.withFileLock(memPath + '.lock', () => {
86
+ const existing = (fs.existsSync(memPath) ? safeRead(memPath) : '') || '';
87
+ let next = existing + entry;
88
+ if (Buffer.byteLength(next, 'utf8') > AGENT_MEMORY_BUDGET_BYTES) {
89
+ // Find the last section boundary that keeps us under budget.
90
+ const limit = AGENT_MEMORY_BUDGET_BYTES;
91
+ // Keep the header (everything before the first '\n---\n\n### ' boundary)
92
+ // and as many recent sections as fit.
93
+ const firstBoundary = next.indexOf('\n---\n\n### ');
94
+ if (firstBoundary > 0) {
95
+ const header = next.slice(0, firstBoundary);
96
+ const rest = next.slice(firstBoundary);
97
+ // Drop oldest sections until we're under budget.
98
+ const sections = rest.split('\n---\n\n### ').filter(Boolean);
99
+ let trimmed = sections;
100
+ while (trimmed.length > 1 &&
101
+ Buffer.byteLength(header + '\n---\n\n### ' + trimmed.join('\n---\n\n### '), 'utf8') > limit) {
102
+ trimmed = trimmed.slice(1);
103
+ }
104
+ next = header + '\n---\n\n### ' + trimmed.join('\n---\n\n### ');
105
+ if (!next.endsWith('\n')) next += '\n';
106
+ } else {
107
+ // No boundaries — just truncate from the end (rare).
108
+ next = next.slice(-limit);
109
+ }
110
+ log('info', `Pruned knowledge/agents/${agent}.md to stay under ${limit} bytes`);
111
+ }
112
+ safeWrite(memPath, next);
113
+ });
114
+ return true;
115
+ } catch (err) {
116
+ log('warn', `Failed to append to knowledge/agents/${agent}.md: ${err.message}`);
117
+ return false;
118
+ }
119
+ }
120
+
18
121
  // Track in-flight LLM consolidation to prevent concurrent runs
19
122
  let _consolidationInFlight = false;
20
123
  let _consolidationStartedAt = 0;
@@ -174,7 +277,7 @@ function consolidateWithLLM(items, existingNotes, files, config) {
174
277
  fallbackDone = true;
175
278
  if (message) log('warn', message);
176
279
  if (err?.message) log('debug', `LLM error: ${err.message}`);
177
- consolidateWithRegex(items, files);
280
+ consolidateWithRegex(items, files, config);
178
281
  }
179
282
 
180
283
  const llmCall = callLLM(prompt, sysPrompt, {
@@ -250,7 +353,7 @@ function consolidateWithLLM(items, existingNotes, files, config) {
250
353
 
251
354
  safeWrite(NOTES_PATH, newContent);
252
355
  });
253
- classifyToKnowledgeBase(items);
356
+ classifyToKnowledgeBase(items, config);
254
357
  archiveInboxFiles(files);
255
358
  log('info', `LLM consolidation complete: ${files.length} notes processed`);
256
359
  } else {
@@ -267,7 +370,7 @@ function consolidateWithLLM(items, existingNotes, files, config) {
267
370
 
268
371
  // ─── Regex Fallback Consolidation ────────────────────────────────────────────
269
372
 
270
- function consolidateWithRegex(items, files) {
373
+ function consolidateWithRegex(items, files, config) {
271
374
 
272
375
  const allInsights = [];
273
376
  for (const item of items) {
@@ -379,17 +482,21 @@ function consolidateWithRegex(items, files) {
379
482
  }
380
483
  safeWrite(NOTES_PATH, newContent);
381
484
  });
382
- classifyToKnowledgeBase(items);
485
+ classifyToKnowledgeBase(items, config);
383
486
  archiveInboxFiles(files);
384
487
  log('info', `Regex fallback: consolidated ${files.length} notes \u2192 ${deduped.length} insights into notes.md`);
385
488
  }
386
489
 
387
490
  // ─── Knowledge Base Classification ───────────────────────────────────────────
388
491
 
389
- function classifyToKnowledgeBase(items) {
492
+ function classifyToKnowledgeBase(items, config) {
390
493
 
391
494
  if (!fs.existsSync(KNOWLEDGE_DIR)) fs.mkdirSync(KNOWLEDGE_DIR, { recursive: true });
392
495
 
496
+ const knownAgents = config && config.agents
497
+ ? new Set(Object.keys(config.agents).map(k => k.toLowerCase()))
498
+ : null;
499
+
393
500
  const categoryDirs = {};
394
501
  // Include 'general' as fallback category even if not in KB_CATEGORIES
395
502
  const allCategories = KB_CATEGORIES.includes('general') ? KB_CATEGORIES : [...KB_CATEGORIES, 'general'];
@@ -424,6 +531,11 @@ function classifyToKnowledgeBase(items) {
424
531
  } catch (err) {
425
532
  log('warn', `Failed to classify ${item.name} to knowledge base: ${err.message}`);
426
533
  }
534
+
535
+ // Per-agent memory routing — strict superset of broadcast consolidation.
536
+ // Appends the inbox content to knowledge/agents/<agent>.md when the
537
+ // author is a configured team member (skips temp-* and unknown agents).
538
+ appendToAgentMemory(item, knownAgents);
427
539
  }
428
540
 
429
541
  if (classified > 0) {
@@ -474,6 +586,11 @@ module.exports = {
474
586
  consolidateInbox,
475
587
  classifyToKnowledgeBase,
476
588
  checkDuplicateHash,
589
+ // per-agent memory routing
590
+ extractInboxAgent,
591
+ appendToAgentMemory,
592
+ AGENT_MEMORY_DIR,
593
+ AGENT_MEMORY_BUDGET_BYTES,
477
594
  // exported for testing
478
595
  buildConsolidationPrompt,
479
596
  consolidateWithLLM,
@@ -174,6 +174,105 @@ function addToDispatch(item) {
174
174
  return item.id;
175
175
  }
176
176
 
177
+ // ─── Pre-Dispatch Acceptance Criteria Gate (P-a2d6b9c7, Ripley §3) ──────────
178
+ //
179
+ // Optional cheap-LLM validation gate that runs *before* queue insertion so
180
+ // impossible/ambiguous work items are routed to a review queue rather than
181
+ // burning a full agent run. Opt-in via ENGINE_DEFAULTS.enablePreDispatchEval.
182
+ //
183
+ // Wired from engine.js discoverWork(); kept as a separate async wrapper so
184
+ // the existing synchronous addToDispatch() call sites are unaffected.
185
+
186
+ function _persistInvalidWorkItem(item, evaluation) {
187
+ const meta = item?.meta;
188
+ const itemId = meta?.item?.id;
189
+ if (!itemId) return;
190
+ let wiPath;
191
+ try { wiPath = lifecycle().resolveWorkItemPath(meta); } catch { wiPath = null; }
192
+ if (!wiPath) return;
193
+ try {
194
+ mutateWorkItems(wiPath, (items) => {
195
+ if (!Array.isArray(items)) return items;
196
+ const idx = items.findIndex(w => w && w.id === itemId);
197
+ if (idx === -1) return items;
198
+ items[idx]._preDispatchEval = {
199
+ valid: false,
200
+ reason: evaluation.reason || '',
201
+ evaluatedAt: ts(),
202
+ };
203
+ return items;
204
+ }, { skipWriteIfUnchanged: true });
205
+ } catch (e) {
206
+ log('warn', `pre-dispatch-eval: failed to persist reason on ${itemId}: ${e.message}`);
207
+ }
208
+ }
209
+
210
+ function _routeToReviewQueue(item, evaluation) {
211
+ item.id = item.id || `${item.agent || 'unassigned'}-${item.type}-${shared.uid()}`;
212
+ item.created_at = ts();
213
+ item.meta = item.meta && typeof item.meta === 'object' ? item.meta : {};
214
+ item._preDispatchEval = {
215
+ valid: false,
216
+ reason: evaluation.reason || '',
217
+ evaluatedAt: ts(),
218
+ };
219
+ mutateDispatch((dispatch) => {
220
+ dispatch.review = Array.isArray(dispatch.review) ? dispatch.review : [];
221
+ // Dedup against the review queue itself so repeated discovery passes don't
222
+ // flood the queue with duplicates of the same WI.
223
+ const wiId = item.meta?.item?.id;
224
+ if (wiId && dispatch.review.some(d => d?.meta?.item?.id === wiId)) {
225
+ return dispatch;
226
+ }
227
+ dispatch.review.push(item);
228
+ return dispatch;
229
+ });
230
+ }
231
+
232
+ /**
233
+ * Async wrapper around addToDispatch that consults the pre-dispatch validator
234
+ * when ENGINE_DEFAULTS.enablePreDispatchEval is true. Validator failures are
235
+ * fail-open (the item still queues) — this gate must never wedge dispatch.
236
+ *
237
+ * @param {object} item - dispatch item (same shape addToDispatch expects).
238
+ * @param {object} [opts]
239
+ * @param {object} [opts.config] - engine config; defaults to queries.getConfig().
240
+ * @param {Function} [opts.validate] - injection point for tests; defaults to
241
+ * require('./pre-dispatch-eval').validateAcceptanceCriteria.
242
+ * @returns {Promise<string|null>} dispatch id when queued; null when routed to review.
243
+ */
244
+ async function addToDispatchWithValidation(item, opts = {}) {
245
+ const config = opts.config || queries.getConfig();
246
+ const enabled = config?.engine?.enablePreDispatchEval ?? ENGINE_DEFAULTS.enablePreDispatchEval;
247
+ if (!enabled) return addToDispatch(item);
248
+
249
+ const wi = item?.meta?.item;
250
+ const criteria = wi && (wi.acceptance_criteria || wi.acceptanceCriteria);
251
+ if (!Array.isArray(criteria) || criteria.length === 0) {
252
+ return addToDispatch(item);
253
+ }
254
+
255
+ const validate = typeof opts.validate === 'function'
256
+ ? opts.validate
257
+ : require('./pre-dispatch-eval').validateAcceptanceCriteria;
258
+
259
+ let evaluation;
260
+ try {
261
+ evaluation = await validate(wi, { engineConfig: config?.engine });
262
+ } catch (e) {
263
+ log('warn', `pre-dispatch-eval: validator threw — failing open: ${e.message}`);
264
+ return addToDispatch(item);
265
+ }
266
+
267
+ if (!evaluation || evaluation.valid !== false) return addToDispatch(item);
268
+
269
+ _persistInvalidWorkItem(item, evaluation);
270
+ _routeToReviewQueue(item, evaluation);
271
+ log('warn', `pre-dispatch-eval: blocked work item ${wi.id} — ${evaluation.reason || 'criteria not actionable'}`);
272
+ return null;
273
+ }
274
+
275
+
177
276
  function _resolveDispatchProject(projectRef, config) {
178
277
  if (!projectRef) return null;
179
278
  const projects = getProjects(config);
@@ -686,6 +785,7 @@ function cancelPendingWorkItems(wiPath, matchFn, reason) {
686
785
  module.exports = {
687
786
  mutateDispatch,
688
787
  addToDispatch,
788
+ addToDispatchWithValidation,
689
789
  getPrDispatchDedupeKey,
690
790
  isRetryableFailureReason,
691
791
  completeDispatch,
@@ -421,6 +421,25 @@ function renderPlaybook(type, vars) {
421
421
  inertAppendices.push('\n\n---\n\n## Team Notes (MUST READ)\n\n' + notes);
422
422
  }
423
423
 
424
+ // Inject per-agent memory file (knowledge/agents/<agentId>.md) — personal
425
+ // notebook curated by the consolidation pipeline. Capped at the same
426
+ // notes budget; missing file degrades gracefully (silent skip).
427
+ const agentIdForMemory = vars.agent_id;
428
+ if (agentIdForMemory && /^[a-z][a-z0-9-]{0,40}$/i.test(agentIdForMemory) && !String(agentIdForMemory).toLowerCase().startsWith('temp-')) {
429
+ const agentMemPath = path.join(MINIONS_DIR, 'knowledge', 'agents', `${String(agentIdForMemory).toLowerCase()}.md`);
430
+ let agentMem = '';
431
+ try { agentMem = fs.readFileSync(agentMemPath, 'utf8'); } catch { /* optional — file may not exist */ }
432
+ if (agentMem && agentMem.trim()) {
433
+ if (Buffer.byteLength(agentMem, 'utf8') > ENGINE_DEFAULTS.maxNotesPromptBytes) {
434
+ const sections = agentMem.split(/(?=^### )/m);
435
+ const recent = sections.slice(-10).join('') || agentMem;
436
+ const budget = Math.max(0, ENGINE_DEFAULTS.maxNotesPromptBytes);
437
+ agentMem = truncateTextBytes(recent, budget, '\n\n_...agent memory truncated_');
438
+ }
439
+ inertAppendices.push('\n\n---\n\n## Personal Memory (your past learnings — MUST READ)\n\n' + agentMem);
440
+ }
441
+ }
442
+
424
443
  // Inject KB guardrail
425
444
  content += `\n\n---\n\n## Knowledge Base Rules\n\n`;
426
445
  content += `**Never delete, move, or overwrite files in \`knowledge/\`.** The sweep (consolidation engine) is the only process that writes to \`knowledge/\`. If you think a KB file is wrong, note it in your learnings file — do not touch \`knowledge/\` directly.\n`;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * engine/pre-dispatch-eval.js — Cheap LLM gate that screens work items for
3
+ * clear/actionable/testable acceptance criteria *before* they are queued for
4
+ * dispatch. Catches impossible or ambiguous items so a slow/expensive agent
5
+ * run isn't burned on something that can't succeed.
6
+ *
7
+ * Conservative scope (per task contract):
8
+ * - Validation only — never rewrites or "fixes" criteria.
9
+ * - Opt-in via `ENGINE_DEFAULTS.enablePreDispatchEval` (default false) so it
10
+ * can be enabled per-environment without forcing fleet-wide rollout.
11
+ * - Fail-open: any LLM error / runtime-unavailable / parse failure resolves
12
+ * `{ valid: true }` so the gate cannot wedge dispatch on its own.
13
+ *
14
+ * Wired from engine/dispatch.js → addToDispatchWithValidation().
15
+ *
16
+ * Source: Ripley §3 recommendation in
17
+ * knowledge/architecture/2026-05-11-ripley-daily-architecture-bug-review-ripley-s-investigati.md
18
+ * (Daily Architecture & Bug Review — 2026-05-11). Lambert + Rebecca debate
19
+ * rounds reaffirmed: validate-only, no auto-rewrite, opt-in flag.
20
+ */
21
+
22
+ const shared = require('./shared');
23
+ const { log } = shared;
24
+ const { callLLM } = require('./llm');
25
+
26
+ const SYSTEM_PROMPT = 'Output only JSON.';
27
+ const DEFAULT_TIMEOUT_MS = 60000;
28
+ const DEFAULT_MODEL = 'haiku'; // claude shorthand; the runtime adapter expands it (see engine/runtimes/claude.js resolveModel)
29
+
30
+ function _extractCriteria(workItem) {
31
+ if (!workItem || typeof workItem !== 'object') return [];
32
+ const candidates = [workItem.acceptance_criteria, workItem.acceptanceCriteria];
33
+ for (const c of candidates) {
34
+ if (Array.isArray(c) && c.length > 0) return c.map(String).filter(Boolean);
35
+ }
36
+ return [];
37
+ }
38
+
39
+ function _buildPrompt(workItem, criteria) {
40
+ const title = workItem.title || workItem.name || workItem.id || 'untitled';
41
+ const description = (workItem.description || '').trim();
42
+ const lines = [
43
+ `Work item: ${title}`,
44
+ ];
45
+ if (description) lines.push('', 'Description:', description);
46
+ lines.push('', 'Acceptance criteria:');
47
+ for (const c of criteria) lines.push(`- ${c}`);
48
+ lines.push('',
49
+ 'Are these acceptance criteria clear, actionable, and testable?',
50
+ 'Reply with JSON: {"valid": true|false, "reason": "..."}.');
51
+ return lines.join('\n');
52
+ }
53
+
54
+ function _parseResponse(text) {
55
+ let body = String(text || '').trim();
56
+ if (!body) return null;
57
+ const fence = body.match(/```(?:json)?\s*([\s\S]*?)```/i);
58
+ if (fence) body = fence[1].trim();
59
+ // Tolerate prose around the JSON object — grab the first top-level {...}
60
+ if (body[0] !== '{') {
61
+ const m = body.match(/\{[\s\S]*\}/);
62
+ if (m) body = m[0];
63
+ }
64
+ try { return JSON.parse(body); } catch { return null; }
65
+ }
66
+
67
+ /**
68
+ * Validate a work item's acceptance criteria with a fast/cheap LLM call.
69
+ *
70
+ * @param {object} workItem - work item with `acceptance_criteria` (or
71
+ * `acceptanceCriteria`) plus title/description for context.
72
+ * @param {object} [opts]
73
+ * @param {object} [opts.engineConfig] - passed through to callLLM for
74
+ * runtime/model resolution (CC path).
75
+ * @param {string} [opts.model] - explicit model override; defaults to 'haiku'.
76
+ * @param {number} [opts.timeout] - LLM timeout in ms.
77
+ * @returns {Promise<{valid: boolean, reason: string}>}
78
+ */
79
+ async function validateAcceptanceCriteria(workItem, opts = {}) {
80
+ const criteria = _extractCriteria(workItem);
81
+ if (criteria.length === 0) {
82
+ return { valid: true, reason: 'no acceptance criteria to validate' };
83
+ }
84
+
85
+ const prompt = _buildPrompt(workItem, criteria);
86
+ let result;
87
+ try {
88
+ result = await callLLM(prompt, SYSTEM_PROMPT, {
89
+ timeout: Number(opts.timeout) > 0 ? Number(opts.timeout) : DEFAULT_TIMEOUT_MS,
90
+ label: 'pre-dispatch-eval',
91
+ model: opts.model || DEFAULT_MODEL,
92
+ maxTurns: 1,
93
+ direct: true,
94
+ engineConfig: opts.engineConfig,
95
+ });
96
+ } catch (e) {
97
+ log('warn', `pre-dispatch-eval: LLM call threw — failing open: ${e?.message || e}`);
98
+ return { valid: true, reason: `validator error: ${e?.message || 'unknown'}` };
99
+ }
100
+
101
+ if (!result) {
102
+ return { valid: true, reason: 'validator returned no result' };
103
+ }
104
+ if (result.missingRuntime) {
105
+ log('warn', 'pre-dispatch-eval: runtime unavailable — failing open');
106
+ return { valid: true, reason: 'validator runtime unavailable' };
107
+ }
108
+ if (result.code !== 0) {
109
+ log('warn', `pre-dispatch-eval: LLM exit ${result.code} — failing open: ${result.errorMessage || ''}`);
110
+ return { valid: true, reason: `validator exit ${result.code}` };
111
+ }
112
+
113
+ const parsed = _parseResponse(result.text);
114
+ if (!parsed || typeof parsed.valid !== 'boolean') {
115
+ log('warn', 'pre-dispatch-eval: response missing boolean valid field — failing open');
116
+ return { valid: true, reason: 'validator response unparseable' };
117
+ }
118
+ return {
119
+ valid: parsed.valid,
120
+ reason: String(parsed.reason || '').trim() || (parsed.valid ? 'criteria look testable' : 'criteria not clear/actionable/testable'),
121
+ };
122
+ }
123
+
124
+ module.exports = {
125
+ validateAcceptanceCriteria,
126
+ // Exposed for unit testing — engine code MUST go through validateAcceptanceCriteria.
127
+ _extractCriteria,
128
+ _buildPrompt,
129
+ _parseResponse,
130
+ };
package/engine/shared.js CHANGED
@@ -1099,6 +1099,7 @@ const ENGINE_DEFAULTS = {
1099
1099
  ignoredCommentAuthors: [], // comments from these authors are auto-closed and never trigger fixes
1100
1100
  agentBusyReassignMs: 600000, // 10min — reassign work item to another agent if preferred agent is busy beyond this threshold
1101
1101
  ccEffort: null, // effort level for CC/doc-chat (null, 'low', 'medium', 'high')
1102
+ enablePreDispatchEval: false, // opt-in: cheap LLM gate before queueing — see engine/pre-dispatch-eval.js (Ripley §3 recommendation, 2026-05-11 architecture review)
1102
1103
 
1103
1104
  // ── Runtime fleet (P-3b8e5f1d) ──────────────────────────────────────────────
1104
1105
  // Single source of truth for which CLI runtime + model every spawn uses.
package/engine/timeout.js CHANGED
@@ -381,8 +381,20 @@ function checkTimeouts(config) {
381
381
  const processExitCode = parseProcessExitCode(liveLogTail);
382
382
 
383
383
  if (processExitCode !== null) {
384
- completeFromOutput(item, liveLogPath, processExitCode, liveLogTail, hasProcess);
385
- continue; // Skip orphan/hung detection we handled it
384
+ // Guard: only complete (and kill) if the tracked process is actually dead.
385
+ // The sentinel can land in live-output.log while the child is still alive
386
+ // (race with the engine's stdout-forwarder appendFileSync writes, or a
387
+ // premature 'close' event on Windows). Without this guard, completeFromOutput
388
+ // taskkill /F /T's a still-working agent — surfaces as "phantom completion"
389
+ // mid-multi-turn session. Confirmed 2026-05-11: agents on both Copilot and
390
+ // Claude died at turn boundaries with claude/copilot.exe still actively
391
+ // making API calls, with the sentinel + claude events interleaved in the log.
392
+ if (processAlive) {
393
+ log('warn', `${item.id}: [process-exit] code=${processExitCode} sentinel found in live-output.log but tracked process (pid=${trackedProcessPid(procInfo) || '?'}) is still alive — treating sentinel as stale/premature, skipping completion`);
394
+ } else {
395
+ completeFromOutput(item, liveLogPath, processExitCode, liveLogTail, hasProcess);
396
+ continue; // Skip orphan/hung detection — we handled it
397
+ }
386
398
  }
387
399
  // Note: we DO NOT trigger on `"type":"result"` alone. There is a ~1s race between
388
400
  // claude CLI emitting the result event and spawn-agent.js writing [process-exit] —
package/engine.js CHANGED
@@ -110,7 +110,7 @@ function isPipelineBranchName(branchName) {
110
110
 
111
111
  // ─── Dispatch Management (extracted to engine/dispatch.js) ───────────────────
112
112
 
113
- const { mutateDispatch, addToDispatch, isRetryableFailureReason, completeDispatch,
113
+ const { mutateDispatch, addToDispatch, addToDispatchWithValidation, isRetryableFailureReason, completeDispatch,
114
114
  writeInboxAlert, updateAgentStatus, pruneStalePrDispatches } = require('./engine/dispatch');
115
115
 
116
116
  // ─── Timeout / Steering / Idle (extracted to engine/timeout.js) ──────────────
@@ -4257,7 +4257,7 @@ async function discoverWork(config) {
4257
4257
  const allWork = [...allFixes, ...allReviews, ...allWorkItems, ...centralWork];
4258
4258
 
4259
4259
  for (const item of allWork) {
4260
- addToDispatch(item);
4260
+ await addToDispatchWithValidation(item, { config });
4261
4261
  if (item.meta?.dispatchKey) setCooldown(item.meta.dispatchKey);
4262
4262
  if (item.meta?.source === 'pr-human-feedback') {
4263
4263
  clearPendingHumanFeedbackFlag(item.meta.project, item.meta.pr?.id);
@@ -4977,7 +4977,7 @@ module.exports = {
4977
4977
  validateConfig,
4978
4978
 
4979
4979
  // Dispatch management (re-exported from engine/dispatch.js)
4980
- mutateDispatch, addToDispatch, isRetryableFailureReason, completeDispatch, writeInboxAlert, updateAgentStatus, pruneStalePrDispatches,
4980
+ mutateDispatch, addToDispatch, addToDispatchWithValidation, isRetryableFailureReason, completeDispatch, writeInboxAlert, updateAgentStatus, pruneStalePrDispatches,
4981
4981
  activeProcesses, realActivityMap, engineRestartGraceExempt,
4982
4982
  get engineRestartGraceUntil() { return engineRestartGraceUntil; },
4983
4983
  set engineRestartGraceUntil(v) { engineRestartGraceUntil = v; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1869",
3
+ "version": "0.1.1871",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"