@yemi33/minions 0.1.1971 → 0.1.1973

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.
@@ -128,7 +128,7 @@ function renderWorkItems(items) {
128
128
  function editWorkItem(id, source) {
129
129
  const item = allWorkItems.find(i => i.id === id);
130
130
  if (!item) return;
131
- const types = ['implement', 'fix', 'review', 'plan', 'verify', 'decompose', 'meeting', 'investigate', 'refactor', 'test', 'explore', 'ask', 'docs'];
131
+ const types = ['implement', 'fix', 'review', 'plan', 'verify', 'decompose', 'meeting', 'investigate', 'refactor', 'test', 'explore', 'ask', 'docs', 'setup'];
132
132
  const priorities = ['critical', 'high', 'medium', 'low'];
133
133
  const agentOpts = (cmdAgents || []).map(a => '<option value="' + escapeHtml(a.id) + '"' + (item.agent === a.id ? ' selected' : '') + '>' + escapeHtml(a.name) + '</option>').join('');
134
134
  const typeOpts = types.map(t => '<option value="' + t + '"' + ((item.type || 'implement') === t ? ' selected' : '') + '>' + t + '</option>').join('');
@@ -352,7 +352,7 @@ async function submitFeedback(id, source) {
352
352
  }
353
353
 
354
354
  function openCreateWorkItemModal() {
355
- const typeOpts = ['implement', 'fix', 'explore', 'test', 'review', 'ask', 'plan', 'verify', 'decompose', 'meeting', 'docs'].map(t =>
355
+ const typeOpts = ['implement', 'fix', 'explore', 'test', 'review', 'ask', 'plan', 'verify', 'decompose', 'meeting', 'docs', 'setup'].map(t =>
356
356
  '<option value="' + t + '"' + (t === 'implement' ? ' selected' : '') + '>' + t + '</option>'
357
357
  ).join('');
358
358
  const priOpts = ['critical', 'high', 'medium', 'low'].map(p =>
@@ -202,7 +202,7 @@ All knobs live under `engine.managedSpawn` in `engine/shared.js:1500` (`ENGINE_D
202
202
  | `enabled` | `true` | Global kill switch. `false` makes the engine ignore all sidecars + skip the sweep. |
203
203
  | `maxSpecsPerFile` | `5` | Per-agent cap. |
204
204
  | `maxTtlMinutes` | `1440` | Hard cap (24h). |
205
- | `defaultTtlMinutes` | `240` | Fallback when `ttl_minutes` omitted (4h). |
205
+ | `defaultTtlMinutes` | `720` | Fallback when `ttl_minutes` omitted (12h). |
206
206
  | `sweepEvery` | `30` | Ticks between sweeps. Default tick = 60s ⇒ ~30 min. |
207
207
  | `defaultHealthIntervalSec` | `1` | Healthcheck cadence pre-first-healthy. |
208
208
  | `healthBackoffSec` | `30` | Healthcheck cadence post-first-healthy. |
package/engine/cleanup.js CHANGED
@@ -471,6 +471,25 @@ async function runCleanup(config, verbose = false) {
471
471
  } catch (e) { log('warn', `worktree-pool: cleanup lookup failed: ${e.message}`); }
472
472
  const _normalizePoolPath = worktreePool()._normalizePath;
473
473
 
474
+ // W-mpbinmrh001907e9 — managed-spawn cwd anchor protection. After a
475
+ // managed_spawn dispatch succeeds, engine-owned services run with
476
+ // `cwd` inside the dispatch's worktree; the agent is gone so no
477
+ // active dispatch references the branch and the >2h age sweep would
478
+ // reap the worktree from under the live services. Build a list of
479
+ // normalized cwds from engine/managed-processes.json and protect any
480
+ // worktree dir that contains one. Cwd is optional per the schema, so
481
+ // entries without cwd contribute nothing.
482
+ const _managedSpawnCwds = [];
483
+ try {
484
+ const _ms = require('./managed-spawn');
485
+ for (const rec of _ms.listManagedSpecs()) {
486
+ if (rec && typeof rec.cwd === 'string' && rec.cwd.length > 0) {
487
+ try { _managedSpawnCwds.push(path.resolve(rec.cwd)); }
488
+ catch (_e) { /* malformed cwd — skip */ }
489
+ }
490
+ }
491
+ } catch (e) { log('warn', `managed-spawn cwd anchor lookup failed: ${e.message}`); }
492
+
474
493
  // Probe `git branch --show-current` for every worktree in chunks of 5.
475
494
  // Sequential probing was the dominant cost in the cleanup phase
476
495
  // (5–15s tick stall every 10 ticks at 50+ worktrees), but unbounded
@@ -538,6 +557,26 @@ async function runCleanup(config, verbose = false) {
538
557
  if (verbose) console.log(` Skipping worktree ${dir}: pool-borrowed by active dispatch`);
539
558
  }
540
559
 
560
+ // W-mpbinmrh001907e9 — managed-spawn cwd anchor protection.
561
+ // Worktrees backing live managed-spawn cwds must outlive the
562
+ // originating dispatch; the engine-owned services run inside them.
563
+ // Overrides merged-branch and age sweeps because the cwd record is
564
+ // the authoritative signal that something live still uses the dir.
565
+ if (_managedSpawnCwds.length > 0) {
566
+ const _wtPathNorm = path.resolve(wtPath);
567
+ const _wtPathPrefix = _wtPathNorm + path.sep;
568
+ for (const cwd of _managedSpawnCwds) {
569
+ if (cwd === _wtPathNorm || cwd.startsWith(_wtPathPrefix)) {
570
+ isProtected = true;
571
+ if (shouldClean) {
572
+ shouldClean = false;
573
+ if (verbose) console.log(` Skipping worktree ${dir}: managed-spawn cwd anchor`);
574
+ }
575
+ break;
576
+ }
577
+ }
578
+ }
579
+
541
580
  // Also clean worktrees older than 2 hours with no active dispatch referencing them
542
581
  let mtime = Date.now();
543
582
  if (!shouldClean) {
package/engine/cli.js CHANGED
@@ -521,7 +521,14 @@ const commands = {
521
521
  const savedBranch = normalizeSessionBranch(sj?.branch);
522
522
  if (sj?.sessionId && (!expectedBranch || savedBranch === expectedBranch)) {
523
523
  sessionId = sj.sessionId;
524
- } else if (sj?.sessionId && expectedBranch) {
524
+ } else if (sj?.sessionId && expectedBranch && sj?.dispatchId === item.id) {
525
+ // Only warn when the saved session is for THIS dispatch but on the
526
+ // wrong branch — that's a true anomaly worth flagging. The common
527
+ // case — leftover session.json from a previous (now-completed)
528
+ // dispatch on a different branch — is expected and silent, since
529
+ // the engine writes session.json on completion of each dispatch
530
+ // and a fresh dispatch may run on a different branch before
531
+ // saveSession overwrites it (W-mpbn93ou000611b3).
525
532
  shared.log('warn', `Reattach: ignoring session for ${agentId} on branch ${savedBranch || 'unknown'}; expected ${expectedBranch}`);
526
533
  }
527
534
  } catch {}
@@ -25,6 +25,21 @@ const AGENT_MEMORY_BUDGET_BYTES = 25000;
25
25
  // excludes temp-* IDs which we filter separately.
26
26
  const AGENT_ID_PATTERN = /^[a-z][a-z0-9-]{0,40}$/;
27
27
 
28
+ // W-mpbi7qus0011bf77 — per-agent memory reconciliation tunables.
29
+ // Skip reconcile when existing memory is small; one mistaken fact in a tiny
30
+ // file is just noise, not a "stale facts coexisting with corrections" risk.
31
+ const AGENT_MEMORY_RECONCILE_MIN_EXISTING_BYTES = 1024;
32
+ // Cap the existing-memory payload sent to the LLM (use the most recent tail
33
+ // since contradictions usually concern recent assertions).
34
+ const AGENT_MEMORY_RECONCILE_LLM_CAP_BYTES = 10000;
35
+ // Hard safety guard: if the reconciled memory shrinks below this ratio of the
36
+ // pre-reconcile size, the LLM probably went rogue — abort the reconcile and
37
+ // fall back to a plain append.
38
+ const AGENT_MEMORY_RECONCILE_MIN_RETAIN_RATIO = 0.30;
39
+ // Contradiction / correction / failure signal heuristic. Conservative — we'd
40
+ // rather miss a stale fact than reconcile every benign "I learned X" note.
41
+ const AGENT_MEMORY_RECONCILE_SIGNAL_RE = /\b(invalid|rejected|rejection|incorrect|wrong|does not exist|never existed|stale|superseded?|_failureClass|invalid_managed_spawn)\b|(^|\n)\s*(\*\*)?reason:/i;
42
+
28
43
  /**
29
44
  * Extract the authoring agent for an inbox item.
30
45
  * Prefers YAML frontmatter `agent:` field; falls back to filename prefix
@@ -84,31 +99,7 @@ function appendToAgentMemory(item, knownAgents) {
84
99
  try {
85
100
  shared.withFileLock(memPath + '.lock', () => {
86
101
  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
- }
102
+ const next = pruneAgentMemoryToBudget(existing + entry, agent);
112
103
  safeWrite(memPath, next);
113
104
  });
114
105
  return true;
@@ -118,6 +109,273 @@ function appendToAgentMemory(item, knownAgents) {
118
109
  }
119
110
  }
120
111
 
112
+ /**
113
+ * Prune an agent memory file's content to AGENT_MEMORY_BUDGET_BYTES.
114
+ * Drops the oldest sections (after the header) until the result fits.
115
+ * Returns the (possibly identical) content.
116
+ */
117
+ function pruneAgentMemoryToBudget(content, agent) {
118
+ if (Buffer.byteLength(content, 'utf8') <= AGENT_MEMORY_BUDGET_BYTES) return content;
119
+ const limit = AGENT_MEMORY_BUDGET_BYTES;
120
+ let next = content;
121
+ // Keep the header (everything before the first '\n---\n\n### ' boundary)
122
+ // and as many recent sections as fit.
123
+ const firstBoundary = next.indexOf('\n---\n\n### ');
124
+ if (firstBoundary > 0) {
125
+ const header = next.slice(0, firstBoundary);
126
+ const rest = next.slice(firstBoundary);
127
+ const sections = rest.split('\n---\n\n### ').filter(Boolean);
128
+ let trimmed = sections;
129
+ while (trimmed.length > 1 &&
130
+ Buffer.byteLength(header + '\n---\n\n### ' + trimmed.join('\n---\n\n### '), 'utf8') > limit) {
131
+ trimmed = trimmed.slice(1);
132
+ }
133
+ next = header + '\n---\n\n### ' + trimmed.join('\n---\n\n### ');
134
+ if (!next.endsWith('\n')) next += '\n';
135
+ } else {
136
+ next = next.slice(-limit);
137
+ }
138
+ log('info', `Pruned knowledge/agents/${agent}.md to stay under ${limit} bytes`);
139
+ return next;
140
+ }
141
+
142
+ /**
143
+ * Heuristic: does this new entry plausibly contradict / supersede / invalidate
144
+ * something in the existing memory? Conservative on purpose — false positives
145
+ * cost an LLM call, false negatives leave stale facts in place. See
146
+ * AGENT_MEMORY_RECONCILE_SIGNAL_RE.
147
+ */
148
+ function hasReconcileSignals(text) {
149
+ if (!text) return false;
150
+ return AGENT_MEMORY_RECONCILE_SIGNAL_RE.test(String(text));
151
+ }
152
+
153
+ /**
154
+ * Build the LLM prompt for per-agent memory reconciliation. The LLM is asked
155
+ * to identify specific lines/facts in the existing memory that the new entry
156
+ * contradicts, and return literal-string edits in a JSON array.
157
+ */
158
+ function buildReconcilePrompt(existingMemory, newEntryContent, agent) {
159
+ return `You are reconciling an agent's personal memory file ("knowledge/agents/${agent}.md"). The agent has just produced a new inbox note that may contradict, supersede, or invalidate specific facts the file currently asserts as true. Your job is to identify those specific contradictions and propose surgical edits.
160
+
161
+ ## Existing memory file (oldest \u2192 newest, possibly truncated)
162
+
163
+ <existing_memory>
164
+ ${existingMemory}
165
+ </existing_memory>
166
+
167
+ ## New inbox entry (about to be appended)
168
+
169
+ <new_entry>
170
+ ${newEntryContent}
171
+ </new_entry>
172
+
173
+ ## Instructions
174
+
175
+ Read the new entry carefully. Does it contradict, supersede, or invalidate any specific lines or facts in the existing memory?
176
+
177
+ If yes, output a JSON array of edits. Each edit must be shaped:
178
+ \`\`\`
179
+ {"old_text": "<exact verbatim substring from the existing memory>", "new_text": "<replacement, or empty string \\"\\" to strike entirely>", "rationale": "<one-sentence reason>"}
180
+ \`\`\`
181
+
182
+ Rules:
183
+ - \`old_text\` MUST be an EXACT verbatim substring of the existing memory (character-for-character, including punctuation, indentation, and surrounding context). The engine applies edits via literal String.prototype.replace — fuzzy / regex / paraphrased matches will be skipped.
184
+ - Keep \`old_text\` narrowly scoped to the contradicted fact (one line, one bullet, or one short block). Do NOT quote entire sections.
185
+ - Set \`new_text\` to the corrected line(s), or to the empty string \`""\` to strike the line entirely.
186
+ - Only emit edits where the new entry provides clear, factual evidence that the existing assertion is wrong. Speculative or stylistic edits are not allowed.
187
+ - If nothing in the existing memory needs to change, output \`[]\`.
188
+
189
+ Output JSON only. No preamble. No code fences. No explanation outside the JSON.`;
190
+ }
191
+
192
+ /**
193
+ * Parse the LLM's edit array, tolerating modest formatting drift (code fences,
194
+ * trailing prose). Returns an array of { old_text, new_text, rationale }.
195
+ * Invalid entries are silently dropped.
196
+ */
197
+ function parseReconcileEdits(rawText) {
198
+ if (!rawText) return [];
199
+ let txt = String(rawText).trim();
200
+ txt = txt.replace(/^```\w*\n?/m, '').replace(/\n?```\s*$/m, '').trim();
201
+ const start = txt.indexOf('[');
202
+ const end = txt.lastIndexOf(']');
203
+ if (start < 0 || end <= start) return [];
204
+ const slice = txt.slice(start, end + 1);
205
+ let parsed;
206
+ try { parsed = JSON.parse(slice); } catch { return []; }
207
+ if (!Array.isArray(parsed)) return [];
208
+ return parsed
209
+ .filter(e => e && typeof e === 'object' && typeof e.old_text === 'string' && typeof e.new_text === 'string')
210
+ .map(e => ({
211
+ old_text: e.old_text,
212
+ new_text: e.new_text,
213
+ rationale: typeof e.rationale === 'string' ? e.rationale : '',
214
+ }));
215
+ }
216
+
217
+ /**
218
+ * Apply reconcile edits to the existing memory content. Uses literal
219
+ * String.indexOf/replace — no regex. Edits whose `old_text` does not match
220
+ * verbatim are skipped with a warning (LLM probably hallucinated). Each
221
+ * applied edit is wrapped with an HTML comment marker so future audits can
222
+ * see what was reconciled and when.
223
+ */
224
+ function applyReconcileEdits(memoryContent, edits, today) {
225
+ let updated = memoryContent;
226
+ let applied = 0;
227
+ const skipped = [];
228
+ for (const edit of edits) {
229
+ const idx = updated.indexOf(edit.old_text);
230
+ if (idx < 0) {
231
+ skipped.push(edit);
232
+ log('warn', `agent-memory reconcile: old_text not found, skipping edit (${(edit.rationale || '').slice(0, 80)})`);
233
+ continue;
234
+ }
235
+ const rationale = (edit.rationale || 'reconciled').replace(/-->/g, '--&gt;').replace(/\s+/g, ' ').trim();
236
+ const marker = `<!-- reconciled ${today}: ${rationale} -->`;
237
+ const replacement = edit.new_text === '' ? marker : `${marker}\n${edit.new_text}`;
238
+ updated = updated.slice(0, idx) + replacement + updated.slice(idx + edit.old_text.length);
239
+ applied++;
240
+ }
241
+ return { updated, applied, skipped };
242
+ }
243
+
244
+ /**
245
+ * Reconcile-and-append entry point for per-agent memory routing.
246
+ *
247
+ * Decision tree:
248
+ * 1. Agent extraction / known-agent / non-empty content checks — same as
249
+ * appendToAgentMemory.
250
+ * 2. No reconcile signals in the new entry → plain sync append. The LLM
251
+ * cost is reserved for the conservative "this might contradict prior
252
+ * facts" case.
253
+ * 3. Existing memory is trivial (<= 1 KB) → plain sync append.
254
+ * 4. Otherwise: call Haiku via callLLM, ask for surgical edits, apply
255
+ * them, write a .bak of the prior content, then append the new entry.
256
+ * Any LLM failure / 0 applied edits / catastrophic-delete violation
257
+ * falls back to a plain sync append. Reconcile NEVER blocks the
258
+ * consolidation pipeline.
259
+ *
260
+ * Returns a Promise<boolean> for the write outcome (true on success, false
261
+ * on skip). Callers in classifyToKnowledgeBase use fire-and-forget with
262
+ * a .catch() so a hung LLM cannot stall consolidation.
263
+ */
264
+ function reconcileAndAppendToAgentMemory(item, knownAgents, config) {
265
+ const agent = extractInboxAgent(item);
266
+ if (!agent) return Promise.resolve(false);
267
+ if (agent.startsWith('temp-')) return Promise.resolve(false);
268
+ if (!knownAgents || !knownAgents.has(agent)) return Promise.resolve(false);
269
+
270
+ const content = String(item?.content || '').trim();
271
+ if (!content) return Promise.resolve(false);
272
+
273
+ // Fast path: no contradiction signals → plain sync append. The function
274
+ // still returns a resolved Promise so callers can use a uniform interface.
275
+ if (!hasReconcileSignals(content)) {
276
+ return Promise.resolve(appendToAgentMemory(item, knownAgents));
277
+ }
278
+
279
+ if (!fs.existsSync(AGENT_MEMORY_DIR)) {
280
+ try { fs.mkdirSync(AGENT_MEMORY_DIR, { recursive: true }); }
281
+ catch (err) {
282
+ log('warn', `Failed to create agent memory dir: ${err.message}`);
283
+ return Promise.resolve(false);
284
+ }
285
+ }
286
+
287
+ const memPath = path.join(AGENT_MEMORY_DIR, `${agent}.md`);
288
+ const existingInitial = (fs.existsSync(memPath) ? safeRead(memPath) : '') || '';
289
+
290
+ // Fast path: nothing meaningful to contradict yet.
291
+ if (existingInitial.length <= AGENT_MEMORY_RECONCILE_MIN_EXISTING_BYTES) {
292
+ return Promise.resolve(appendToAgentMemory(item, knownAgents));
293
+ }
294
+
295
+ // Build the entry block exactly as appendToAgentMemory would so reconcile
296
+ // and plain-append produce identical entry framing.
297
+ const titleMatch = content.match(/^#\s+(.+)/m);
298
+ const title = titleMatch ? titleMatch[1].trim() : (item.name || 'untitled').replace(/\.md$/, '');
299
+ const entry = `\n\n---\n\n### ${dateStamp()}: ${title}\n_Source: \`notes/inbox/${item.name}\`_\n\n${content}\n`;
300
+
301
+ const memoryForLlm = existingInitial.length > AGENT_MEMORY_RECONCILE_LLM_CAP_BYTES
302
+ ? existingInitial.slice(-AGENT_MEMORY_RECONCILE_LLM_CAP_BYTES)
303
+ : existingInitial;
304
+ const prompt = buildReconcilePrompt(memoryForLlm, content, agent);
305
+ const sysPrompt = 'You output ONLY a JSON array of edits, exactly as specified. No preamble. No explanation. No code fences.';
306
+
307
+ let llmCall;
308
+ try {
309
+ llmCall = callLLM(prompt, sysPrompt, {
310
+ timeout: 60000,
311
+ label: 'agent_memory_reconcile',
312
+ model: 'haiku',
313
+ maxTurns: 1,
314
+ direct: true,
315
+ engineConfig: config?.engine,
316
+ });
317
+ } catch (err) {
318
+ log('warn', `agent-memory reconcile: callLLM threw (${err?.message || err}) — plain append`);
319
+ return Promise.resolve(appendToAgentMemory(item, knownAgents));
320
+ }
321
+
322
+ return Promise.resolve(llmCall).then((result) => {
323
+ try { trackEngineUsage('agent_memory_reconcile', result?.usage); } catch { /* metrics best-effort */ }
324
+
325
+ if (!result || result.missingRuntime || result.code !== 0) {
326
+ log('warn', `agent-memory reconcile: LLM unavailable/failed for ${agent} (code=${result?.code}) — plain append`);
327
+ return appendToAgentMemory(item, knownAgents);
328
+ }
329
+
330
+ const edits = parseReconcileEdits(result.text || result.raw || '');
331
+ if (edits.length === 0) {
332
+ // LLM said "no contradictions" (or returned garbage) — plain append.
333
+ return appendToAgentMemory(item, knownAgents);
334
+ }
335
+
336
+ let reconciled = false;
337
+ let lockErr = null;
338
+ try {
339
+ shared.withFileLock(memPath + '.lock', () => {
340
+ const beforeLock = (fs.existsSync(memPath) ? safeRead(memPath) : '') || '';
341
+ const { updated, applied, skipped } = applyReconcileEdits(beforeLock, edits, dateStamp());
342
+
343
+ if (applied === 0) {
344
+ log('warn', `agent-memory reconcile: 0/${edits.length} edits matched for ${agent} — plain append`);
345
+ return;
346
+ }
347
+
348
+ // Hard safety guard: catastrophic delete.
349
+ const preBytes = Buffer.byteLength(beforeLock, 'utf8');
350
+ const postBytes = Buffer.byteLength(updated, 'utf8');
351
+ if (preBytes > 0 && postBytes < preBytes * AGENT_MEMORY_RECONCILE_MIN_RETAIN_RATIO) {
352
+ log('warn', `agent-memory reconcile: post-edit ${postBytes}B < ${Math.round(AGENT_MEMORY_RECONCILE_MIN_RETAIN_RATIO * 100)}% of pre-edit ${preBytes}B for ${agent} — aborting, plain append`);
353
+ return;
354
+ }
355
+
356
+ // One-step backup before destructive write.
357
+ try { safeWrite(memPath + '.bak', beforeLock); }
358
+ catch (err) { log('warn', `agent-memory reconcile: backup write failed for ${agent}: ${err.message}`); }
359
+
360
+ const next = pruneAgentMemoryToBudget(updated + entry, agent);
361
+ safeWrite(memPath, next);
362
+ reconciled = true;
363
+ const skippedCount = skipped.length;
364
+ log('info', `agent-memory reconcile: applied ${applied}/${edits.length} edits${skippedCount ? ` (${skippedCount} skipped)` : ''} to knowledge/agents/${agent}.md`);
365
+ });
366
+ } catch (err) {
367
+ lockErr = err;
368
+ }
369
+
370
+ if (reconciled) return true;
371
+ if (lockErr) log('warn', `agent-memory reconcile: lock/write error for ${agent}: ${lockErr.message} — plain append`);
372
+ return appendToAgentMemory(item, knownAgents);
373
+ }).catch((err) => {
374
+ log('warn', `agent-memory reconcile: LLM promise rejected for ${agent} (${err?.message || err}) — plain append`);
375
+ return appendToAgentMemory(item, knownAgents);
376
+ });
377
+ }
378
+
121
379
  // Track in-flight LLM consolidation to prevent concurrent runs
122
380
  let _consolidationInFlight = false;
123
381
  let _consolidationStartedAt = 0;
@@ -535,7 +793,20 @@ function classifyToKnowledgeBase(items, config) {
535
793
  // Per-agent memory routing — strict superset of broadcast consolidation.
536
794
  // Appends the inbox content to knowledge/agents/<agent>.md when the
537
795
  // author is a configured team member (skips temp-* and unknown agents).
538
- appendToAgentMemory(item, knownAgents);
796
+ // When the new entry has contradiction signals, the reconcile pass calls
797
+ // Haiku to identify and rewrite stale facts before the append. Reconcile
798
+ // is fire-and-forget — any failure or hang falls back to plain append
799
+ // inside reconcileAndAppendToAgentMemory; the consolidation pipeline is
800
+ // never blocked on the LLM. (W-mpbi7qus0011bf77)
801
+ try {
802
+ const p = reconcileAndAppendToAgentMemory(item, knownAgents, config);
803
+ if (p && typeof p.catch === 'function') {
804
+ p.catch(err => log('warn', `agent-memory reconcile/append failed: ${err?.message || err}`));
805
+ }
806
+ } catch (err) {
807
+ log('warn', `agent-memory reconcile/append threw: ${err?.message || err}`);
808
+ appendToAgentMemory(item, knownAgents);
809
+ }
539
810
  }
540
811
 
541
812
  if (classified > 0) {
@@ -589,8 +860,17 @@ module.exports = {
589
860
  // per-agent memory routing
590
861
  extractInboxAgent,
591
862
  appendToAgentMemory,
863
+ reconcileAndAppendToAgentMemory,
864
+ pruneAgentMemoryToBudget,
865
+ hasReconcileSignals,
866
+ buildReconcilePrompt,
867
+ parseReconcileEdits,
868
+ applyReconcileEdits,
592
869
  AGENT_MEMORY_DIR,
593
870
  AGENT_MEMORY_BUDGET_BYTES,
871
+ AGENT_MEMORY_RECONCILE_MIN_EXISTING_BYTES,
872
+ AGENT_MEMORY_RECONCILE_LLM_CAP_BYTES,
873
+ AGENT_MEMORY_RECONCILE_MIN_RETAIN_RATIO,
594
874
  // exported for testing
595
875
  buildConsolidationPrompt,
596
876
  consolidateWithLLM,
@@ -346,6 +346,8 @@ function isRetryableFailureReason(reason = '', failureClass = '') {
346
346
  FAILURE_CLASS.WORKTREE_PREFLIGHT, // pre-spawn worktree validation — recompute will produce the same failure
347
347
  FAILURE_CLASS.INVALID_KEEP_PROCESSES_WORKDIR, // W-mp6k7ywi000fa33c — keep-pids cwd is not a real git worktree; re-running won't fix the structural issue
348
348
  FAILURE_CLASS.INVALID_KEEP_PROCESSES_SCHEMA, // W-mp7i902u000l991f — keep-pids.json failed shape validation; re-running with the same wrong file won't fix it
349
+ FAILURE_CLASS.INVALID_MANAGED_SPAWN, // W-mpbhxg3b000u8411 — managed-spawn.json failed validation; re-running with the same wrong file won't fix it
350
+ FAILURE_CLASS.MANAGED_SPAWN_HEALTHCHECK_FAILED, // W-mpbhxg3b000u8411 — healthcheck timed out; agent must fix the spec or the service it spawned
349
351
  ]);
350
352
  if (neverRetry.has(failureClass)) return false;
351
353
  }
@@ -393,6 +395,23 @@ function isCompletedWorkItemForFailure(item) {
393
395
  );
394
396
  }
395
397
 
398
+ // W-mpbhxg3b000u8411 — Failure classes that should force-demote a `done` work
399
+ // item back to `failed`. These are the hard sidecar-acceptance and
400
+ // healthcheck rejections fired in engine.js's onAgentClose AFTER
401
+ // runPostCompletionHooks has already flipped the WI to DONE based on the
402
+ // completion-report's verdict:"success". The agent's "I'm done" claim is
403
+ // structurally false when the sidecar didn't pass validation — so the
404
+ // dispatch must be allowed to demote the WI rather than silently ignoring the
405
+ // rejection (incident W-mpbg0jpt0007f7fe). PR-shipped WIs are still
406
+ // protected: the demotion only fires when the WI has no _pr/_prUrl, since
407
+ // these acceptance gates never run for spawns that produced a PR.
408
+ const FORCE_DEMOTE_FAILURE_CLASSES = new Set([
409
+ FAILURE_CLASS.INVALID_KEEP_PROCESSES_WORKDIR,
410
+ FAILURE_CLASS.INVALID_KEEP_PROCESSES_SCHEMA,
411
+ FAILURE_CLASS.INVALID_MANAGED_SPAWN,
412
+ FAILURE_CLASS.MANAGED_SPAWN_HEALTHCHECK_FAILED,
413
+ ]);
414
+
396
415
  function readLiveWorkItem(meta) {
397
416
  const itemId = meta?.item?.id;
398
417
  if (!itemId) return null;
@@ -533,10 +552,12 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
533
552
  // Update source work item status on failure + auto-retry with backoff
534
553
  const retryableFailure = agentRetryable !== undefined ? agentRetryable : isRetryableFailureReason(reason, failureClass);
535
554
  let completedWorkItemFailure = false;
555
+ let liveWi = null;
536
556
  if (processWorkItemFailure && result === DISPATCH_RESULT.ERROR && item.meta?.item?.id) {
537
557
  // If the live item cannot be resolved, keep the existing retry path.
538
558
  try {
539
- completedWorkItemFailure = isCompletedWorkItemForFailure(readLiveWorkItem(item.meta));
559
+ liveWi = readLiveWorkItem(item.meta);
560
+ completedWorkItemFailure = isCompletedWorkItemForFailure(liveWi);
540
561
  } catch (e) { log('warn', 'read live work item before retry: ' + e.message); }
541
562
  }
542
563
  if (result === DISPATCH_RESULT.ERROR && item.meta?.dispatchKey && retryableFailure && !completedWorkItemFailure) {
@@ -545,7 +566,40 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
545
566
 
546
567
  if (processWorkItemFailure && result === DISPATCH_RESULT.ERROR && item.meta?.item?.id) {
547
568
  if (completedWorkItemFailure) {
548
- log('info', `Dispatch error for ${item.meta.item.id} ignored work item is already completed`);
569
+ // W-mpbhxg3b000u8411 sidecar acceptance / healthcheck rejections fire
570
+ // AFTER runPostCompletionHooks has already flipped the WI to DONE based
571
+ // on the agent's completion-report verdict. The completion-report's
572
+ // success claim is structurally false when the sidecar didn't pass
573
+ // validation, so demote DONE → FAILED with the dispatch's failure
574
+ // class. PR-shipped WIs are explicitly NOT demoted — refuse to unship
575
+ // merged work even if a stale acceptance error somehow arrives late.
576
+ const isPrShipped = !!liveWi && (!!liveWi._pr || !!liveWi._prUrl);
577
+ const isForceDemote = failureClass && FORCE_DEMOTE_FAILURE_CLASSES.has(failureClass) && !isPrShipped;
578
+ if (isForceDemote) {
579
+ try {
580
+ const wiPath = lifecycle().resolveWorkItemPath(item.meta);
581
+ if (wiPath) {
582
+ const classSuffix = ` [${failureClass.toUpperCase().replace(/-/g, '_')}]`;
583
+ const demoteReason = `Non-retryable failure: ${reason || failureClass}${classSuffix}`;
584
+ mutateWorkItems(wiPath, items => {
585
+ const wi = items.find(i => i.id === item.meta.item.id);
586
+ if (wi) {
587
+ wi.status = WI_STATUS.FAILED;
588
+ wi.failReason = demoteReason;
589
+ wi.failedAt = ts();
590
+ wi._failureClass = failureClass;
591
+ wi._lastDispatchResult = DISPATCH_RESULT.ERROR;
592
+ delete wi.completedAt;
593
+ delete wi._noop;
594
+ delete wi._noopReason;
595
+ }
596
+ });
597
+ log('warn', `Demoted WI ${item.meta.item.id} from DONE → FAILED${classSuffix} — sidecar acceptance gate rejected after completion-report claimed success`);
598
+ }
599
+ } catch (e) { log('warn', `force-demote on ${failureClass}: ${e.message}`); }
600
+ } else {
601
+ log('info', `Dispatch error for ${item.meta.item.id} ignored — work item is already completed`);
602
+ }
549
603
  } else {
550
604
  let retries = (item.meta.item._retryCount || 0);
551
605
  try {
@@ -602,6 +656,8 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
602
656
  [FAILURE_CLASS.WORKTREE_PREFLIGHT]: 'worktree preflight rejected (nested in project root or rootDir collapsed to drive root)',
603
657
  [FAILURE_CLASS.INVALID_KEEP_PROCESSES_WORKDIR]: 'keep_processes cwd is not a real git worktree (rerun in a `git worktree add` directory)',
604
658
  [FAILURE_CLASS.INVALID_KEEP_PROCESSES_SCHEMA]: 'keep-pids.json failed shape validation (wrong keys/types/values — see inbox alert for the canonical shape)',
659
+ [FAILURE_CLASS.INVALID_MANAGED_SPAWN]: 'managed-spawn.json failed validation (bad schema, workdir, or allowlist — see inbox alert)',
660
+ [FAILURE_CLASS.MANAGED_SPAWN_HEALTHCHECK_FAILED]: 'managed-spawn spec(s) failed healthcheck within timeout (failing PIDs killed; surviving siblings stay alive)',
605
661
  [FAILURE_CLASS.UNKNOWN]: 'unknown error',
606
662
  };
607
663
  const classLabel = failureClass ? (CLASS_LABELS[failureClass] || failureClass) : '';
@@ -945,6 +945,12 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
945
945
 
946
946
  function isPrAttachmentRequired(type, item, meta = {}) {
947
947
  if (!item?.id || item.skipPr) return false;
948
+ // SETUP (W-mpbi6f2q00104957) is implicitly PR-exempt — the type itself
949
+ // communicates "no PR expected" (boot/configure local infrastructure that
950
+ // mutates state but produces no PR). Callers don't need to also set
951
+ // skipPr:true. This early return beats the explicit-flag fallthrough so a
952
+ // legacy requiresPr:true setup item doesn't trip the contract.
953
+ if (type === WORK_TYPE.SETUP) return false;
948
954
  const explicit = item.requiresPr === true
949
955
  || item.prRequired === true
950
956
  || item.requiresPullRequest === true
@@ -3766,8 +3772,24 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
3766
3772
  } catch (err) { log('warn', `Scheduled task back-reference: ${err.message}`); }
3767
3773
  }
3768
3774
 
3769
- // Clean up worktree for non-shared-branch tasks after completion
3770
- if (meta?.branch && meta?.branchStrategy !== 'shared-branch') {
3775
+ // Clean up worktree for non-shared-branch tasks after completion.
3776
+ //
3777
+ // W-mpbinmrh001907e9 — skip worktree removal when this dispatch carried
3778
+ // the `keep_processes` or `managed_spawn` flag (either at the top-level
3779
+ // dispatch meta or nested under `meta.item.meta`). Those flags' acceptance
3780
+ // gates run in engine.js onAgentClose AFTER runPostCompletionHooks
3781
+ // returns, and their workdir validator checks `<cwd>` on disk. If the
3782
+ // worktree is deleted here first, every sidecar pointing at the worktree
3783
+ // fails with a bogus `invalid-workdir: directory does not exist`
3784
+ // rejection. The cleanup sweep (engine/cleanup.js) reaps the worktree
3785
+ // afterwards: when acceptance fails, the >2h age sweep picks it up; when
3786
+ // acceptance succeeds, the managed-spawn cwd anchor in cleanup.js keeps
3787
+ // the worktree alive while the engine-owned services run inside it.
3788
+ const _wiMetaForSkip = meta?.item?.meta || {};
3789
+ const _skipWorktreeRemovalForAcceptance =
3790
+ !!meta?.keep_processes || !!_wiMetaForSkip.keep_processes ||
3791
+ !!meta?.managed_spawn || !!_wiMetaForSkip.managed_spawn;
3792
+ if (meta?.branch && meta?.branchStrategy !== 'shared-branch' && !_skipWorktreeRemovalForAcceptance) {
3771
3793
  try {
3772
3794
  const project = meta.project || {};
3773
3795
  const rootDir = project.localPath ? path.resolve(project.localPath) : null;
@@ -308,6 +308,7 @@ const PLAYBOOK_REQUIRED_VARS = {
308
308
  'verify': ['task_description'],
309
309
  'test': ['item_name'],
310
310
  'docs': ['item_id', 'item_name'],
311
+ 'setup': ['item_id', 'item_name', 'project_path'],
311
312
  'work-item': ['item_id', 'item_name'],
312
313
  'meeting-investigate': ['meeting_title', 'agenda'],
313
314
  'meeting-debate': ['meeting_title', 'agenda'],
@@ -859,7 +860,7 @@ function selectPlaybook(workType, item) {
859
860
  if (workType === WORK_TYPE.FIX && hasPrContext) {
860
861
  return 'fix';
861
862
  }
862
- const typeSpecificPlaybooks = ['explore', 'review', 'test', 'plan-to-prd', 'plan', 'ask', 'verify', 'decompose', 'docs', 'meeting-investigate', 'meeting-debate', 'meeting-conclude'];
863
+ const typeSpecificPlaybooks = ['explore', 'review', 'test', 'plan-to-prd', 'plan', 'ask', 'verify', 'decompose', 'docs', 'setup', 'meeting-investigate', 'meeting-debate', 'meeting-conclude'];
863
864
  return typeSpecificPlaybooks.includes(workType) ? workType : 'work-item';
864
865
  }
865
866
 
package/engine/shared.js CHANGED
@@ -1505,7 +1505,7 @@ const ENGINE_DEFAULTS = {
1505
1505
  maxEnvVars: 32, // env-object cap per spec
1506
1506
  maxAttrsBytes: 2048, // serialized `attrs` blob cap per spec
1507
1507
  maxTtlMinutes: 1440, // 24h hard cap on per-spec TTL
1508
- defaultTtlMinutes: 240, // 4h default when spec.ttl_minutes omitted
1508
+ defaultTtlMinutes: 720, // 12h default when spec.ttl_minutes omitted
1509
1509
  sweepEvery: 30, // ticks between TTL/dead-PID sweeps
1510
1510
  defaultHealthIntervalSec: 1, // healthcheck polling cadence pre-healthy
1511
1511
  healthBackoffSec: 30, // healthcheck liveness cadence post-healthy
@@ -1949,6 +1949,13 @@ const WORK_TYPE = {
1949
1949
  IMPLEMENT: 'implement', IMPLEMENT_LARGE: 'implement:large', FIX: 'fix', REVIEW: 'review',
1950
1950
  VERIFY: 'verify', PLAN: 'plan', PLAN_TO_PRD: 'plan-to-prd', DECOMPOSE: 'decompose',
1951
1951
  MEETING: 'meeting', EXPLORE: 'explore', ASK: 'ask', TEST: 'test', DOCS: 'docs',
1952
+ // SETUP (W-mpbi6f2q00104957): "boot/configure local infrastructure" tasks
1953
+ // that mutate state inside a project worktree but produce NO PR. Canonical
1954
+ // example: bootstrapping a constellation dev stack via managed_spawn.
1955
+ // Implicitly PR-exempt — isPrAttachmentRequired short-circuits on this type
1956
+ // so callers don't need to also set skipPr:true. Still worktree-requiring
1957
+ // and still project-required (mirrors implement/fix HTTP-validate path).
1958
+ SETUP: 'setup',
1952
1959
  };
1953
1960
 
1954
1961
  // Work types whose dispatch path requires a per-project git worktree. The
@@ -1976,6 +1983,12 @@ const WORKTREE_REQUIRING_TYPES = new Set([
1976
1983
  WORK_TYPE.VERIFY,
1977
1984
  WORK_TYPE.REVIEW,
1978
1985
  WORK_TYPE.DECOMPOSE,
1986
+ // SETUP (W-mpbi6f2q00104957): setup dispatches mutate project state inside
1987
+ // a real worktree (running `bun install`, writing `.env.local`, etc.) so a
1988
+ // project is required even though no PR is produced. Without a project the
1989
+ // worktree resolver falls back to MINIONS_DIR's parent and collapses to a
1990
+ // drive root on installs where MINIONS_DIR sits one level below the root.
1991
+ WORK_TYPE.SETUP,
1979
1992
  WORK_TYPE.DOCS,
1980
1993
  ]);
1981
1994
 
@@ -529,9 +529,44 @@ function main() {
529
529
  clearTimeout(startupTimer);
530
530
  clearTimeout(initialSnapshotTimer);
531
531
  clearInterval(descTimer);
532
+
533
+ // Compute the exit code and write the [process-exit] sentinel FIRST,
534
+ // before the descendant snapshot/reap. The engine's orphan reaper uses
535
+ // the sentinel as the single signal that "the runtime exited cleanly
536
+ // with code N"; if we delay it behind `snapshotDescendants()` (which
537
+ // shells out to `Get-CimInstance` on Windows and can block 1-5+s),
538
+ // there's a window where the runtime PID we track is already dead but
539
+ // no sentinel exists yet. After an engine restart that path triggers
540
+ // `canReapDeadProcess` and the dispatch gets auto-retried as orphaned
541
+ // even though it completed normally. See W-mpbn93ou000611b3 / the
542
+ // 2026-05-18 ripley-explore regression.
543
+ //
544
+ // Prefer the 'exit' event's code/signal when present (Node's 'close'
545
+ // event can report code=0 on Windows when the OS-level exit was
546
+ // non-zero — see the long-form note above the exit handler).
547
+ const effectiveCode = (realExitFromEvent != null) ? realExitFromEvent : code;
548
+ const effectiveSignal = realSignalFromEvent || signal;
549
+ const exitCode = normalizeRuntimeExit(effectiveCode, effectiveSignal);
550
+ if (sentinelWritten) {
551
+ // Defense-in-depth: never write a duplicate sentinel. We observed pairs
552
+ // of [process-exit] code=0 lines in live-output.log across many failed
553
+ // runs, which suggests close has fired twice in some edge cases (e.g.,
554
+ // shim re-launch on Windows). One sentinel per spawn is the contract.
555
+ // Skip descendant reap on the duplicate close too — the first close
556
+ // already handled it (reaping the same PIDs again is a no-op at best,
557
+ // but skipping is faster and matches the prior early-return contract).
558
+ fs.appendFileSync(debugPath, `EXIT (duplicate close, skipping sentinel): code=${exitCode}${effectiveSignal ? ` signal=${effectiveSignal}` : ''}\n`);
559
+ process.exit(exitCode);
560
+ return;
561
+ }
562
+ sentinelWritten = true;
563
+ const sentinelResult = writeProcessExitSentinel({ exitCode, signal: effectiveSignal });
564
+
532
565
  // Final snapshot + reap, but only when the runtime actually spawned
533
566
  // children. Read-only / very short agents (exit before the 3s initial
534
- // snapshot fires) skip the wmic shell-out entirely.
567
+ // snapshot fires) skip the wmic shell-out entirely. Runs AFTER the
568
+ // sentinel write so a slow Get-CimInstance call can't gate completion
569
+ // detection — see the hoist note above.
535
570
  if (trackedDescendants.size || gotFirstOutput) {
536
571
  snapshotDescendants();
537
572
  if (trackedDescendants.size) {
@@ -580,21 +615,6 @@ function main() {
580
615
  try { fs.appendFileSync(debugPath, `DESCENDANTS reaped=${reaped}/${toKillPids.length} kept=${kept.length}\n`); } catch {}
581
616
  }
582
617
  }
583
- // Prefer the 'exit' event's code/signal when present — see note above.
584
- const effectiveCode = (realExitFromEvent != null) ? realExitFromEvent : code;
585
- const effectiveSignal = realSignalFromEvent || signal;
586
- const exitCode = normalizeRuntimeExit(effectiveCode, effectiveSignal);
587
- if (sentinelWritten) {
588
- // Defense-in-depth: never write a duplicate sentinel. We observed pairs
589
- // of [process-exit] code=0 lines in live-output.log across many failed
590
- // runs, which suggests close has fired twice in some edge cases (e.g.,
591
- // shim re-launch on Windows). One sentinel per spawn is the contract.
592
- fs.appendFileSync(debugPath, `EXIT (duplicate close, skipping sentinel): code=${exitCode}${effectiveSignal ? ` signal=${effectiveSignal}` : ''}\n`);
593
- process.exit(exitCode);
594
- return;
595
- }
596
- sentinelWritten = true;
597
- const sentinelResult = writeProcessExitSentinel({ exitCode, signal: effectiveSignal });
598
618
  fs.appendFileSync(debugPath, `EXIT: code=${exitCode}${effectiveSignal ? ` signal=${effectiveSignal}` : ''} (close=${code} exit=${realExitFromEvent})\nSTDERR: ${stderrBuf.slice(0, 500)}\n`);
599
619
  if (!sentinelResult.fileWritten) {
600
620
  fs.appendFileSync(debugPath, `EXIT SENTINEL: file write failed for ${process.env.MINIONS_LIVE_OUTPUT_PATH}\n`);
package/engine.js CHANGED
@@ -2526,7 +2526,11 @@ async function spawnAgent(dispatchItem, config) {
2526
2526
  // during the git ops — otherwise pruneStale() racing on another tick
2527
2527
  // could see borrowedBy as orphaned and evict the entry mid-return.
2528
2528
  // Skipped when keep_processes PIDs are still alive: the worktree may be
2529
- // the cwd of a left-running dev server or watcher.
2529
+ // the cwd of a left-running dev server or watcher. W-mpbinmrh001907e9:
2530
+ // also skipped when managed_spawn just placed live services with cwd
2531
+ // inside the worktree — `git reset --hard` + `git clean -fd` here would
2532
+ // corrupt those services' state and detach them from the branch they
2533
+ // expect.
2530
2534
  if (effectiveResult === DISPATCH_RESULT.SUCCESS && worktreePath && fs.existsSync(worktreePath)) {
2531
2535
  let _keepPidsAlive = false;
2532
2536
  try {
@@ -2534,10 +2538,16 @@ async function spawnAgent(dispatchItem, config) {
2534
2538
  const _anchorRes = _ks.getActiveAnchorPidsForAgent(agentId);
2535
2539
  if (_anchorRes && _anchorRes.pids && _anchorRes.pids.size > 0) _keepPidsAlive = true;
2536
2540
  } catch (_e) { /* keep-process-sweep import optional — fall through */ }
2541
+ // W-mpbinmrh001907e9 — when managed-spawn accepted + spawned services
2542
+ // this dispatch, treat the worktree the same as a keep_processes-anchored
2543
+ // one: skip pool return + evict any stale pool entry. We rely on the
2544
+ // in-scope spawn count (truth-at-this-moment) rather than a state-file
2545
+ // re-read because the spawn happened seconds ago in this same handler.
2546
+ const _managedSpawnAlive = Array.isArray(managedSpawnSpawned) && managedSpawnSpawned.length > 0;
2537
2547
 
2538
2548
  const _projForReturn = project?.name || 'default';
2539
2549
  const _poolSizeReturn = worktreePool.getProjectPoolSize(_projForReturn, config);
2540
- if (!_keepPidsAlive && _poolSizeReturn > 0) {
2550
+ if (!_keepPidsAlive && !_managedSpawnAlive && _poolSizeReturn > 0) {
2541
2551
  try {
2542
2552
  const _mainRefRet = sanitizeBranch(shared.resolveMainBranch(rootDir, project?.mainBranch));
2543
2553
  await shared.shellSafeGit(['reset', '--hard', 'HEAD'], { ..._gitOpts, cwd: worktreePath, timeout: 30000 });
@@ -2560,10 +2570,12 @@ async function spawnAgent(dispatchItem, config) {
2560
2570
  log('warn', `worktree-pool: return failed for ${worktreePath}: ${returnErr.message} — evicting from pool`);
2561
2571
  worktreePool.evictEntry(worktreePath, 'return-git-failed');
2562
2572
  }
2563
- } else if (_keepPidsAlive) {
2564
- // Skip the pool — the worktree is in use by left-running processes.
2565
- // Make sure no stale entry lingers (defensive).
2566
- worktreePool.evictEntry(worktreePath, 'keep-processes-alive');
2573
+ } else if (_keepPidsAlive || _managedSpawnAlive) {
2574
+ // Skip the pool — the worktree is in use by left-running processes
2575
+ // (keep_processes PIDs or managed-spawn services). Make sure no
2576
+ // stale entry lingers (defensive).
2577
+ const _reason = _managedSpawnAlive ? 'managed-spawn-alive' : 'keep-processes-alive';
2578
+ worktreePool.evictEntry(worktreePath, _reason);
2567
2579
  }
2568
2580
  }
2569
2581
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1971",
3
+ "version": "0.1.1973",
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"
@@ -0,0 +1,113 @@
1
+ # Playbook: Setup
2
+
3
+ You are {{agent_name}}, the {{agent_role}} on the {{project_name}} project.
4
+ TEAM ROOT: {{team_root}}
5
+
6
+ Repository ID is injected as `{{ado_project}}` and `{{repo_name}}` template variables.
7
+ Repo: {{repo_name}} | Org: {{ado_org}} | Project: {{ado_project}}
8
+
9
+ ## Your Task
10
+
11
+ Setup task **{{item_id}}: {{item_name}}**
12
+ - Priority: {{item_priority}}
13
+ - Description: {{item_description}}
14
+
15
+ {{additional_context}}
16
+
17
+ {{references}}
18
+
19
+ {{acceptance_criteria}}
20
+
21
+ ## What "setup" means
22
+
23
+ `setup` work items boot or configure local infrastructure inside a project
24
+ worktree. Canonical examples: bootstrap a dev stack (`bun install`, `npm
25
+ install`, write `.env.local`, seed local databases), launch a long-running
26
+ dev server via `meta.managed_spawn`, prime a sandbox/emulator. They mutate
27
+ state but **produce no pull request**.
28
+
29
+ If your assignment requires committing files to the tracked repo (real code
30
+ or doc changes), it's the wrong type — escalate so the dispatcher reclassifies
31
+ it as `implement` / `fix` / `docs` instead of forcing a PR-less merge.
32
+
33
+ ## Working directory
34
+
35
+ You are running inside a real project worktree. Confirm the path before doing
36
+ anything filesystem-sensitive:
37
+
38
+ ```bash
39
+ # PowerShell
40
+ echo $env:MINIONS_AGENT_CWD
41
+ pwd
42
+
43
+ # bash/zsh
44
+ echo "$MINIONS_AGENT_CWD"
45
+ pwd
46
+ ```
47
+
48
+ `MINIONS_AGENT_CWD` is the engine-resolved worktree root and is the
49
+ authoritative path for cwd-sensitive commands. If it disagrees with `pwd`,
50
+ prefer `MINIONS_AGENT_CWD` and `cd` there before continuing.
51
+
52
+ ## Project Scope
53
+
54
+ Primary repo: **{{repo_name}}** ({{ado_org}}/{{ado_project}}) at `{{project_path}}`
55
+
56
+ ## No PR expected
57
+
58
+ This is the defining contract of `setup`:
59
+
60
+ - **Do NOT run `git push`.** No remote branch is needed.
61
+ - **Do NOT run `gh pr create`** (or any host-specific PR creation command).
62
+ - **Do NOT create a new branch** beyond the engine's pre-created worktree branch.
63
+ - The engine's PR-attachment contract is short-circuited for `type: "setup"`,
64
+ so completing `done` with no PR is the correct outcome.
65
+
66
+ If you accidentally produce code changes that need review, stop, leave them
67
+ uncommitted, and report what happened in the completion report so the human
68
+ can re-dispatch as `implement` or `fix`.
69
+
70
+ ## Managed spawn / keep-process hints
71
+
72
+ If the dispatcher set `meta.managed_spawn` or `meta.keep_processes` on this
73
+ work item, the engine auto-injects the corresponding contract block above
74
+ this playbook. Follow the contract block verbatim — it tells you exactly
75
+ which sidecar file to write (`agents/<id>/managed-spawn.json` or
76
+ `agents/<id>/keep-pids.json`), the schema, and the engine-side reap behavior.
77
+ Both injectors are orthogonal to work-item type, so they work the same here
78
+ as in `implement`.
79
+
80
+ ## Health Check
81
+
82
+ Before starting work, run `git status` to confirm the worktree is clean.
83
+ If it's dirty or on an unexpected branch, report and stop.
84
+
85
+ ## Working Style
86
+
87
+ Use subagents only for genuinely parallel, independent tasks. For sequential
88
+ work, single-file edits, searches, and file reads, work directly.
89
+
90
+ ## Validation
91
+
92
+ Prove the setup actually worked before declaring success — the agent's
93
+ completion is the only signal the engine and user get that the infrastructure
94
+ is up:
95
+
96
+ - Use the project's documented commands (`CLAUDE.md`, README, `package.json`
97
+ scripts) — don't invent ones.
98
+ - For long-running services, hit the documented health endpoint / health
99
+ command before exiting (e.g. `curl http://localhost:<port>/health`).
100
+ - Capture the exact commands you ran in the completion report. Do not write
101
+ "setup completed" without naming what ran.
102
+
103
+ Long installs and dev-stack boots may be quiet for several minutes. Let the
104
+ normal CLI command run naturally; do not add artificial heartbeat output.
105
+
106
+ ## When to Stop
107
+
108
+ Your task is complete when the requested infrastructure is up, validated by
109
+ the project's own health command, and the completion report names the
110
+ commands you ran and the URLs / PIDs of anything left running. There is no
111
+ PR to wait for.
112
+
113
+ Do NOT remove the worktree — the engine handles cleanup automatically.
@@ -145,10 +145,10 @@ curl -s http://localhost:{{dashboard_port}}/api/status
145
145
  ```
146
146
 
147
147
  **Required fields per endpoint** — the server returns `{ error: "..." }` if missing. Common cases:
148
- - `POST /api/work-items`: `title` REQUIRED. `description` recommended. `project` REQUIRED when multiple projects are configured (server returns the list of known names if you guess wrong). `type` defaults to `implement`; valid values: `fix`, `implement`, `implement:large`, `explore`, `ask`, `review`, `test`, `verify`. Agent hint via `agent` (string) or `agents` (array).
149
- - Exempt from the `project` requirement (these run rootless or via central paths): `ask`, `explore`, `plan`, `plan-to-prd`, `meeting`. (`docs` is intentionally NOT exempt — it's write-capable and lands in `WORKTREE_REQUIRING_TYPES`, so it needs a real project worktree. For minions-repo docs work, pass `project: "minions"` explicitly.) Every other type needs a project worktree, so the server rejects project-less creates with `400 { error, knownProjects }` when ≠1 project is configured.
148
+ - `POST /api/work-items`: `title` REQUIRED. `description` recommended. `project` REQUIRED when multiple projects are configured (server returns the list of known names if you guess wrong). `type` defaults to `implement`; valid values: `fix`, `implement`, `implement:large`, `setup`, `explore`, `ask`, `review`, `test`, `verify`. Agent hint via `agent` (string) or `agents` (array).
149
+ - Exempt from the `project` requirement (these run rootless or via central paths): `ask`, `explore`, `plan`, `plan-to-prd`, `meeting`. (`docs` is intentionally NOT exempt — it's write-capable and lands in `WORKTREE_REQUIRING_TYPES`, so it needs a real project worktree. For minions-repo docs work, pass `project: "minions"` explicitly.) `setup` is also in the project-required set — it operates inside a real project worktree but produces no PR. Every other type needs a project worktree, so the server rejects project-less creates with `400 { error, knownProjects }` when ≠1 project is configured.
150
150
  - **`meta.keep_processes: true`** — opt-in flag that lets the agent leave specific descendant PIDs running after it exits (default: engine reaps EVERYTHING the agent spawned). **Set this whenever the user's intent is to leave a process alive after the agent finishes** — e.g. "spin up the dev server and exit", "start the watcher and leave it running", "set up my dev env", "keep the emulator open", "launch the daemon for me", "boot the constellation host and disconnect". Don't set it for normal build/test/run-once tasks (`npm test`, `npm run build`, one-shot scripts) — those should be reaped. Also accepts optional `meta.keep_processes_ttl_minutes` (default 60, hard-cap 1440 = 24h). When you set this flag, also make the WI title/description say something like "leave the dev server running" so the agent knows to write `agents/<id>/keep-pids.json` before exiting (the playbook injects the contract automatically when the flag is on). Example: `-d '{"title":"Spin up Constellation dev env and leave server running","type":"implement","project":"constellation","description":"Run bun install + bun run dev. Leave the dev server (port 5173) and Constellation host (port 3001) running after you exit so the user can iterate.","meta":{"keep_processes":true,"keep_processes_ttl_minutes":240}}'`. Inspect / kill kept PIDs anytime via `GET /api/keep-processes` and `POST /api/keep-processes/kill`.
151
- - **`skipPr: true`** — opt-in flag that tells the engine NOT to enforce the PR-attachment contract for this work item, so the WI can complete `done` without the missing-PR hard-fail. **Set this when the dispatch mutates state OUTSIDE any tracked git repo and therefore cannot produce a PR** — e.g. cleaning `~/.claude/skills/`, editing runtime config under `~/.config/`, resetting the dashboard cache, mutating engine JSON state files (`engine/*.json`) the engine itself owns, or local tooling installs. **Do NOT set it for any task that touches a tracked repo's source** — even one-line diffs in a real repo should produce a PR. Type-selection rule of thumb: prefer `type: "explore"` for genuinely read-only tasks (rootless, no worktree, no PR contract); use `skipPr: true` when the task is write-side mutation but the writes don't land in a git repo. Example: `-d '{"title":"Clean up duplicate skills in ~/.claude/skills","type":"implement","description":"Audit ~/.claude/skills/ and delete the 3 obsolete entries identified in NOTE-mp7gt4iw0004b879. Pure user-machine state outside any git repo, so no PR will be produced.","skipPr":true}'`.
151
+ - **`skipPr: true`** — opt-in flag that tells the engine NOT to enforce the PR-attachment contract for this work item, so the WI can complete `done` without the missing-PR hard-fail. **Set this when the dispatch mutates state OUTSIDE any tracked git repo and therefore cannot produce a PR** — e.g. cleaning `~/.claude/skills/`, editing runtime config under `~/.config/`, resetting the dashboard cache, mutating engine JSON state files (`engine/*.json`) the engine itself owns, or local tooling installs. **Do NOT set it for any task that touches a tracked repo's source** — even one-line diffs in a real repo should produce a PR. Type-selection rule of thumb: prefer `type: "setup"` for infra/dev-env bootstrap tasks that mutate project state but produce no PR (it's implicitly PR-exempt — no `skipPr` needed); prefer `type: "explore"` for genuinely read-only tasks (rootless, no worktree, no PR contract); use `skipPr: true` only when the task is write-side mutation but the writes don't land in a git repo and `setup` doesn't fit (e.g. cleaning user-machine state outside any repo). Example: `-d '{"title":"Bootstrap Constellation dev stack","type":"setup","project":"constellation","description":"Run bun install + bun run dev and leave the dev server running.","meta":{"managed_spawn":true}}'`.
152
152
  - **`oneShot: true`** — opt-in flag for one-off human-initiated dispatches that should NOT enroll the discovered PR into the engine's automatic review/fix loop. The PR is still tracked (status + comments are polled normally) but `discoverFromPrs` skips it for review/fix dispatch. **Set this when the user's intent is "do this single action against an existing PR, then stop"** — e.g. "review PR #2533 once", "rebase PR #2540 once and exit", "post a fix-summary comment on PR #2519". Don't set it for normal feature/fix work where the PR should keep cycling through review/fix until merged. Example: `-d '{"title":"One-off review of PR #2533","type":"review","project":"minions","description":"Single review pass on github:yemi33/minions#2533. Do not re-dispatch on subsequent comments.","oneShot":true}'`.
153
153
  - `POST /api/notes`: `title`, `what` REQUIRED.
154
154
  - `POST /api/knowledge`: `category`, `title`, `content` REQUIRED. Categories: `architecture`, `conventions`, `project-notes`, `build-reports`, `reviews`.
package/routing.md CHANGED
@@ -20,6 +20,7 @@ How the engine decides who handles what. Parsed by engine.js — keep the table
20
20
  | decompose | ripley | rebecca |
21
21
  | meeting | ripley | lambert |
22
22
  | docs | lambert | _any_ |
23
+ | setup | dallas | _any_ |
23
24
 
24
25
  Notes:
25
26
  - `_author_` means route to the PR author