@yemi33/minions 0.1.2313 → 0.1.2315

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.
@@ -136,7 +136,8 @@ const RENDER_VERSIONS = {
136
136
  inbox: 2,
137
137
  // Bumped 4→5 for the clickable checkout-mode pill + picker (W-mr1b67zi0006b788).
138
138
  // Bumped 5→6 for multi-select hybrid liveValidation.type support (W-mr2m1ute000a9c01).
139
- projects: 6,
139
+ // Bumped 6→7 for the liveValidation.autoDispatch toggle + pill "· auto-validate" suffix (W-mr2q361a00097e5c).
140
+ projects: 7,
140
141
  notes: 1,
141
142
  prd: 3,
142
143
  prs: 3,
@@ -149,7 +150,9 @@ const RENDER_VERSIONS = {
149
150
  dispatch: 2,
150
151
  engineLog: 2,
151
152
  metrics: 1,
152
- workItems: 7,
153
+ // Bumped 7→8: WI detail modal's branch pill moved out of the Artifacts
154
+ // chip row into its own non-interactive "Branch" field (W-mr2qjr3b0002522f).
155
+ workItems: 8,
153
156
  skills: 1,
154
157
  commands: 1,
155
158
  mcpServers: 1,
@@ -112,7 +112,15 @@ function _renderWorktreeModePill(p) {
112
112
  if (types.length > 0) {
113
113
  const typesText = types.join(', ');
114
114
  const vt = escapeHtml(typesText);
115
- return ' <span class="project-mode-pill project-mode-hybrid project-mode-pill-clickable"' + common + ' title="Hybrid live-validation — coding work items author in isolated worktrees; only the &quot;' + vt + '&quot; validation type(s) run in-place on the live checkout (capped to one mutating dispatch, refused on a dirty tree). Click to change.">⚡ Hybrid · ' + vt + '</span>';
115
+ // W-mr2q361a00097e5c visibly distinguish "Hybrid (manual validation)"
116
+ // from "Hybrid (auto-validate)" so the operator doesn't have to reopen
117
+ // the picker to discover whether autoDispatch is silently off.
118
+ const autoDispatch = p.liveValidationAutoDispatch === true;
119
+ const autoSuffix = autoDispatch ? ' · auto-validate' : '';
120
+ const autoTitleBit = autoDispatch
121
+ ? ' A validation work item is automatically dispatched after each coding work item completes.'
122
+ : ' Auto-validation is OFF — validation must be dispatched manually after each coding work item completes.';
123
+ return ' <span class="project-mode-pill project-mode-hybrid project-mode-pill-clickable"' + common + ' title="Hybrid live-validation — coding work items author in isolated worktrees; only the &quot;' + vt + '&quot; validation type(s) run in-place on the live checkout (capped to one mutating dispatch, refused on a dirty tree).' + escHtml(autoTitleBit) + ' Click to change.">⚡ Hybrid · ' + vt + escHtml(autoSuffix) + '</span>';
116
124
  }
117
125
  return ' <span class="project-mode-pill project-mode-live project-mode-pill-clickable"' + common + ' title="Live-checkout dispatch mode — agents run in-place inside the project working tree (no isolated worktree); capped to one mutating dispatch and refused on a dirty tree. Click to change.">⚡ Live checkout</span>';
118
126
  }
@@ -276,6 +284,24 @@ function _renderCheckoutModeHybridStep(menu, projectName) {
276
284
  });
277
285
  menu.appendChild(checkboxList);
278
286
 
287
+ // W-mr2q361a00097e5c — "auto-validate" toggle for liveValidation.autoDispatch.
288
+ // Default UNCHECKED to match today's implicit default (autoDispatch absent
289
+ // / false) so reopening the picker on a project that has never set this
290
+ // never silently flips it on; preselect from the project's CURRENT value
291
+ // when one is already configured (mirrors the type-checkbox preselect
292
+ // above) so reopening an already-hybrid project reflects its real state.
293
+ const autoDispatchRow = document.createElement('label');
294
+ autoDispatchRow.className = 'checkout-mode-menu-checkbox-row checkout-mode-menu-autodispatch-row';
295
+ const autoDispatchCb = document.createElement('input');
296
+ autoDispatchCb.type = 'checkbox';
297
+ autoDispatchCb.className = 'checkout-mode-menu-checkbox checkout-mode-menu-autodispatch-checkbox';
298
+ autoDispatchCb.checked = project.liveValidationAutoDispatch === true;
299
+ const autoDispatchText = document.createElement('span');
300
+ autoDispatchText.textContent = 'Automatically validate after each coding work item completes';
301
+ autoDispatchRow.appendChild(autoDispatchCb);
302
+ autoDispatchRow.appendChild(autoDispatchText);
303
+ menu.appendChild(autoDispatchRow);
304
+
279
305
  const actions = document.createElement('div');
280
306
  actions.className = 'checkout-mode-menu-actions';
281
307
 
@@ -297,8 +323,9 @@ function _renderCheckoutModeHybridStep(menu, projectName) {
297
323
  applyBtn.addEventListener('click', function() {
298
324
  const types = checkboxes.filter(function(cb) { return cb.checked; }).map(function(cb) { return cb.value; });
299
325
  if (types.length === 0) return; // require at least one selected type
326
+ const autoDispatch = autoDispatchCb.checked;
300
327
  _closeCheckoutModeMenu();
301
- _applyCheckoutModeChange(projectName, 'live', types);
328
+ _applyCheckoutModeChange(projectName, 'live', types, autoDispatch);
302
329
  });
303
330
  actions.appendChild(applyBtn);
304
331
 
@@ -316,18 +343,33 @@ function _renderCheckoutModeHybridStep(menu, projectName) {
316
343
  // whichever shape is passed through is what gets POSTed as
317
344
  // liveValidation.type, so shared.validateLiveValidation on the server decides
318
345
  // the final persisted shape.
319
- async function _applyCheckoutModeChange(projectName, newMode, liveValidationType) {
346
+ //
347
+ // `autoDispatch` (W-mr2q361a00097e5c, optional, only meaningful for hybrid
348
+ // mode) threads the "auto-validate" checkbox value through to the POST body
349
+ // as liveValidation.autoDispatch. Non-hybrid callers (Worktrees / full Live
350
+ // checkout) omit it, which correctly clears autoDispatch when leaving hybrid
351
+ // mode (liveValidation is null entirely in that case).
352
+ async function _applyCheckoutModeChange(projectName, newMode, liveValidationType, autoDispatch) {
320
353
  const project = _findProjectInLastStatus(projectName);
321
354
  if (!project) return;
322
355
  const typesForLabel = liveValidationType
323
356
  ? (Array.isArray(liveValidationType) ? liveValidationType : [liveValidationType])
324
357
  : [];
358
+ const autoDispatchOn = typesForLabel.length > 0 && !!autoDispatch;
325
359
  const label = newMode === 'worktree'
326
360
  ? 'Worktrees'
327
361
  : (typesForLabel.length > 0 ? ('Hybrid (' + typesForLabel.join(', ') + ')') : 'Live checkout');
362
+ // W-mr2q361a00097e5c — auto-validation is a workflow-changing decision
363
+ // (agents stop running local builds once it's on), so call it out
364
+ // explicitly in the confirm message rather than burying it in the label.
365
+ const autoDispatchNote = typesForLabel.length > 0
366
+ ? (autoDispatchOn
367
+ ? ' Auto-validation will be turned ON — a validation work item will be dispatched automatically after each coding work item completes, and coding agents will skip local builds/tests.'
368
+ : ' Auto-validation will be OFF — you will need to dispatch validation manually after each coding work item completes.')
369
+ : '';
328
370
  const ok = await confirmDialog({
329
371
  title: 'Change checkout mode?',
330
- message: 'Switch "' + projectName + '" to ' + label + '? This changes how every future dispatch on this project runs.',
372
+ message: 'Switch "' + projectName + '" to ' + label + '?' + autoDispatchNote + ' This changes how every future dispatch on this project runs.',
331
373
  confirmLabel: 'Change mode',
332
374
  cancelLabel: 'Cancel',
333
375
  danger: newMode !== 'worktree',
@@ -336,11 +378,13 @@ async function _applyCheckoutModeChange(projectName, newMode, liveValidationType
336
378
 
337
379
  const prevMode = project.checkoutMode;
338
380
  const prevType = project.liveValidationType;
381
+ const prevAutoDispatch = project.liveValidationAutoDispatch;
339
382
  const nextType = (newMode === 'live' && liveValidationType) ? liveValidationType : null;
340
383
  // Optimistic flip BEFORE awaiting the API call (per dashboard convention —
341
384
  // see projectChipRemove / removePinnedNote); reverted in the catch below.
342
385
  project.checkoutMode = newMode;
343
386
  project.liveValidationType = nextType;
387
+ project.liveValidationAutoDispatch = nextType ? autoDispatchOn : false;
344
388
  if (window._lastStatus && Array.isArray(window._lastStatus.projects)) renderProjects(window._lastStatus.projects);
345
389
 
346
390
  try {
@@ -351,7 +395,7 @@ async function _applyCheckoutModeChange(projectName, newMode, liveValidationType
351
395
  projects: [{
352
396
  name: projectName,
353
397
  checkoutMode: newMode,
354
- liveValidation: nextType ? { type: nextType } : null,
398
+ liveValidation: nextType ? { type: nextType, autoDispatch: autoDispatchOn } : null,
355
399
  }],
356
400
  }),
357
401
  });
@@ -361,6 +405,7 @@ async function _applyCheckoutModeChange(projectName, newMode, liveValidationType
361
405
  } catch (err) {
362
406
  project.checkoutMode = prevMode;
363
407
  project.liveValidationType = prevType;
408
+ project.liveValidationAutoDispatch = prevAutoDispatch;
364
409
  if (window._lastStatus && Array.isArray(window._lastStatus.projects)) renderProjects(window._lastStatus.projects);
365
410
  showToast('cmd-toast', 'Failed to update checkout mode: ' + (err && err.message || err), false);
366
411
  }
@@ -889,8 +889,6 @@ function _wiRenderDetail(item) {
889
889
  // chip is needed.
890
890
  var arts = item._artifacts || {};
891
891
  var artPills = '';
892
- var branchStyle = 'display:inline-flex;align-items:center;gap:3px;padding:2px 8px;border-radius:10px;font-size:var(--text-sm);background:var(--surface2);border:1px solid var(--border);color:var(--text);cursor:default';
893
- if (arts.branch) artPills += '<span style="' + branchStyle + '">🌿 ' + escapeHtml(arts.branch) + '</span> ';
894
892
  if (arts.plan) artPills += renderArtifactLink({ type: 'plan', id: arts.plan, label: 'Plan' }) + ' ';
895
893
  if (arts.prd) artPills += renderArtifactLink({ type: 'prd', id: arts.prd, label: 'PRD' }) + ' ';
896
894
  if (arts.sourcePlan) artPills += renderArtifactLink({ type: 'plan', id: arts.sourcePlan, label: 'Source Plan' }) + ' ';
@@ -915,6 +913,13 @@ function _wiRenderDetail(item) {
915
913
  });
916
914
  }
917
915
  if (artPills) html += field('Artifacts', '<div style="display:flex;flex-wrap:wrap;gap:4px">' + artPills + '</div>');
916
+ // Branch is not a clickable artifact link (no navigation target), so it is
917
+ // rendered as its own plain-text field rather than sharing the Artifacts
918
+ // pill row's chip styling — matches render-prs.js's PR-modal "Branch:"
919
+ // field (dashboard/js/render-prs.js:517), which uses a plain <code> tag
920
+ // instead of the clickable-chip pill style (issue: branch pill looked like
921
+ // a dead artifact link).
922
+ if (arts.branch) html += field('Branch', '🌿 <code style="font-size:var(--text-sm);background:var(--surface2);padding:2px 6px;border-radius:var(--radius-sm)">' + escapeHtml(arts.branch) + '</code>');
918
923
 
919
924
  // P-d5a6f7c4 (Harness Transparency, Stage 3 — surface). Render the harness
920
925
  // usage the agent self-reported. Shape:
@@ -1341,6 +1341,9 @@
1341
1341
  }
1342
1342
  .checkout-mode-menu-checkbox-row:hover { background: var(--surface); }
1343
1343
  .checkout-mode-menu-checkbox { cursor: pointer; }
1344
+ .checkout-mode-menu-autodispatch-row {
1345
+ margin-top: 6px; padding-top: 10px; border-top: 1px solid var(--border);
1346
+ }
1344
1347
  .checkout-mode-menu-actions {
1345
1348
  display: flex; justify-content: flex-end; gap: 6px; padding-top: 4px;
1346
1349
  }
package/dashboard.js CHANGED
@@ -1156,16 +1156,16 @@ function findWorkItemsTargetById(id, source, projects = PROJECTS) {
1156
1156
  if (explicitSource) {
1157
1157
  const target = resolveProjectSourceTarget(source, projects);
1158
1158
  if (target.error) return { error: target.error };
1159
- const items = shared.safeJson(target.wiPath) || [];
1159
+ const items = shared.safeJsonArr(target.wiPath);
1160
1160
  return { ...target, found: items.some(i => i.id === id) };
1161
1161
  }
1162
1162
 
1163
1163
  const central = resolveProjectSourceTarget('central', projects);
1164
- const centralItems = shared.safeJson(central.wiPath) || [];
1164
+ const centralItems = shared.safeJsonArr(central.wiPath);
1165
1165
  if (centralItems.some(i => i.id === id)) return { ...central, found: true };
1166
1166
  for (const project of projects) {
1167
1167
  const target = resolveProjectSourceTarget(project.name, projects);
1168
- const items = shared.safeJson(target.wiPath) || [];
1168
+ const items = shared.safeJsonArr(target.wiPath);
1169
1169
  if (items.some(i => i.id === id)) return { ...target, found: true };
1170
1170
  }
1171
1171
  return { found: false };
@@ -2769,6 +2769,13 @@ function _buildStatusSlowState() {
2769
2769
  liveValidationType: (shared.resolveCheckoutMode(p) === 'live' && p.liveValidation && p.liveValidation.type)
2770
2770
  ? p.liveValidation.type
2771
2771
  : null,
2772
+ // W-mr2q361a00097e5c — surface liveValidation.autoDispatch alongside
2773
+ // .type so the hybrid picker can preselect the "auto-validate"
2774
+ // checkbox and the pill can visibly distinguish manual vs
2775
+ // auto-validating hybrid projects. Only meaningful when checkoutMode
2776
+ // is 'live'; false otherwise (matches the implicit today-default of
2777
+ // autoDispatch being absent/false).
2778
+ liveValidationAutoDispatch: !!(shared.resolveCheckoutMode(p) === 'live' && p.liveValidation && p.liveValidation.autoDispatch === true),
2772
2779
  };
2773
2780
  }),
2774
2781
  autoMode: {
@@ -8069,7 +8076,7 @@ const server = http.createServer(async (req, res) => {
8069
8076
  const config = queries.getConfig();
8070
8077
  const allWorkItems = queries.getWorkItems(config);
8071
8078
  const planWis = allWorkItems.filter(w => w.sourcePlan === body.file && w.itemType !== 'pr' && w.itemType !== 'verify');
8072
- const allPrs = PROJECTS.flatMap(p => shared.safeJson(shared.projectPrPath(p)) || []);
8079
+ const allPrs = PROJECTS.flatMap(p => shared.safeJsonArr(shared.projectPrPath(p)));
8073
8080
  const prLinks = shared.getPrLinks();
8074
8081
  const implContext = (plan.missing_features || []).map(f => {
8075
8082
  const wi = planWis.find(w => w.id === f.id);
@@ -11004,7 +11011,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11004
11011
  async function handleSettingsUpdate(req, res) {
11005
11012
  try {
11006
11013
  const body = await readBody(req);
11007
- const config = safeJson(CONFIG_PATH) || {};
11014
+ const config = safeJsonObj(CONFIG_PATH);
11008
11015
  if (!config.engine) config.engine = {};
11009
11016
  if (!config.agents) config.agents = {};
11010
11017
  shared.pruneDefaultClaudeConfig(config);
@@ -13298,7 +13305,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13298
13305
  // (Round-2 review finding #3.)
13299
13306
  const cfg = queries.getConfig() || {};
13300
13307
  const scheds = cfg.schedules || [];
13301
- const runs = shared.safeJson(path.join(ENGINE_DIR, 'schedule-runs.json')) || {};
13308
+ const runs = shared.safeJsonObj(path.join(ENGINE_DIR, 'schedule-runs.json'));
13302
13309
  return scheds.map(s => {
13303
13310
  const runEntry = runs[s.id];
13304
13311
  const _lastRun = typeof runEntry === 'string' ? runEntry : (runEntry?.lastRun || runEntry?.lastCompletedAt || null);
@@ -13507,7 +13514,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13507
13514
 
13508
13515
  // KB pin state (server-side so CC can pin items)
13509
13516
  { method: 'GET', path: '/api/kb-pins', desc: 'Get pinned KB item keys', handler: async (req, res) => {
13510
- const pins = shared.safeJson(KB_PINS_PATH) || [];
13517
+ const pins = shared.safeJsonArr(KB_PINS_PATH);
13511
13518
  return jsonReply(res, 200, { pins });
13512
13519
  }},
13513
13520
  { method: 'POST', path: '/api/kb-pins', desc: 'Set pinned KB item keys', params: 'pins[]', handler: async (req, res) => {
@@ -12,6 +12,7 @@ The engine runs a tick every 10 seconds (configurable via `config.json` → `eng
12
12
  tick()
13
13
  1. checkTimeouts() Enforce runtime limits and stale-orphan cleanup
14
14
  1a. checkSteering() Drain steering messages queued by the dashboard
15
+ 1a2 checkSpawnPhaseStalls() Watchdog for agents stuck before their first heartbeat
15
16
  1b. checkIdleThreshold() Notify on excessive agent idleness
16
17
  1c. meetingTimeouts() Advance round-based meetings whose timer fired
17
18
  2. consolidateInbox() Merge learnings into notes.md (Haiku-powered)
@@ -31,6 +32,8 @@ tick()
31
32
  3a. pruneStalePrDispatches() Clear pending PR dispatches whose underlying PRs no longer warrant action
32
33
  3. discoverWork() Scan ALL linked projects for new tasks
33
34
  4. updateSnapshot() Write identity/now.md
35
+ 4a. memoryBaseline() Sample RSS/heap/GC every memoryBaselineEveryTicks ticks (default 6 ≈ 60s)
36
+ 4b. heapSnapshotRequest() Serve a pending guard-token heap-snapshot request, if any
34
37
  5. dispatch Spawn agents for pending items (up to maxConcurrent)
35
38
  ```
36
39
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Author: Rebecca (Architect) | Date: 2026-04-07 | Status: **Accepted — implementation in progress**
4
4
 
5
- > **Implementation status (as of 2026-06):** The `node:sqlite` recommendation in §3 has been adopted ahead of schedule. Phases 0–10 have shipped (events, dispatches, work_items, pull_requests, logs, metrics, watches, schedule_runs + pipeline_runs + managed_processes + worktree_pool, qa_runs + qa_sessions, pr_links, cooldowns + pending_rebases + cc_sessions + doc_sessions, and steering_deliveries (Phases 0–9); plans + prds + prd_items + prd_verify_prs (Phase 10, migration `015-plans-prds.js`) — see `CHANGELOG.md` and `engine/db/migrations/`). The SQLite schema lives under `engine/db/migrations/` and the singleton opens `engine/state.db` in WAL mode. Phase 8 added the first opt-out toggle for the JSON sidecars (`engine.qaDualWriteJson`, default true). Phase 9.4 went further and deleted the silent SQL-unavailable JSON fallbacks in the engine — SQL is now the only reader/writer for everything migrated; the JSON mirror layer is dual-written as a passive mirror and slated for deletion in Phase 9.5. Phase 10 dual-writes PRD state to SQL (`engine/prd-store.js`) and flips reads to SQL when the `prdReadsFromSql` feature flag is ON (default ON reversible). The "Phase 2: estimated Node 26 LTS" timeline in §3 is now historical context; treat sections 1–3 as design rationale rather than a forward plan.
5
+ > **Implementation status (as of 2026-06):** The `node:sqlite` recommendation in §3 has been adopted ahead of schedule. Phases 0–10 have shipped (events, dispatches, work_items, pull_requests, logs, metrics, watches, schedule_runs + pipeline_runs + managed_processes + worktree_pool, qa_runs + qa_sessions, pr_links, cooldowns + pending_rebases + cc_sessions + doc_sessions, and steering_deliveries (Phases 0–9); plans + prds + prd_items + prd_verify_prs (Phase 10, migration `015-plans-prds.js`) — see `CHANGELOG.md` and `engine/db/migrations/`). The SQLite schema lives under `engine/db/migrations/` and the singleton opens `engine/state.db` in WAL mode. Phase 8 added the first opt-out toggle for the JSON sidecars (`engine.qaDualWriteJson`, default true). Phase 9.4 went further and deleted the silent SQL-unavailable JSON fallbacks in the engine — SQL is now the only reader/writer for everything migrated; the JSON mirror layer is dual-written as a passive mirror and slated for deletion in Phase 9.5. Phase 10 dual-writes PRD state to SQL (`engine/prd-store.js`); the `prdReadsFromSql` read-flip flag was removed after soaking (default had been ON since 0.1.2090) — SQL is now the unconditional PRD read path in `engine/queries.js`, with SQL errors propagating instead of silently falling back to a disk scan. Phase 10 step 4.3 added a second, still-flagged toggle, `prdJoinFromFk`: it resolves the dashboard WI↔PRD-item join via the SQL FK (`work_items.prd_item_id` → `prd_items.id`) instead of the legacy feature-id string match, with a fallback to the string match when the flag is OFF or the FK is unstamped/NULL (default ON, reversible, expires 2026-12-01). The "Phase 2: estimated Node 26 LTS" timeline in §3 is now historical context; treat sections 1–3 as design rationale rather than a forward plan.
6
6
 
7
7
  ## Executive Summary
8
8
 
@@ -81,6 +81,10 @@ A paused cause **suppresses further auto-dispatch for that cause** until cleared
81
81
 
82
82
  The exported `recordPrNoOpFixAttempt` / `clearPrNoOpFixAttempt` / `sweepStalePrNoOpFixes` helpers in `engine/lifecycle.js` are the only sanctioned entry points. The dispatch evaluator gates re-dispatch on `shared.isPrNoOpFixCausePaused(pr, cause)` (which re-validates the fingerprint per call), the dashboard chip surface uses `shared.getPrPausedCauses(pr)` (same gate), and the `pollPrStatus` callback in `engine/ado.js` + `engine/github.js` calls `sweepStalePrNoOpFixes(pr)` each tick to GC records whose fingerprint no longer matches the live PR.
83
83
 
84
+ #### E2. Build-fix-ineffective tracker (`pr._buildFixIneffective`, issue #639)
85
+
86
+ A "branch unchanged" no-op report for a `BUILD_FAILURE`-cause fix used to be conflated with the generic `_noOpFixes[BUILD_FAILURE]` counter even when the build was still genuinely red on the unchanged head — silently masking a stuck failure as "handled". `checkBuildStillFailingLive()` re-polls host-specific live CI (`checkLiveBuildAndConflict` in `engine/github.js` / `engine/ado.js`) right after `detectPrFixBranchChange()` reports no branch change for a build-failure fix. If the build is still failing, `recordBuildFixIneffective()` tracks a **separate**, `headSha`-keyed counter (`pr._buildFixIneffective`) instead of bumping `_noOpFixes[BUILD_FAILURE]`, pausing after `prNoOpFixPauseAttempts` on the same head and writing a distinct `build-fix-ineffective-<pr>` inbox alert; `shared.isBuildFixIneffectivePaused()` re-validates the stored `headSha` against the live PR before honoring the pause (same fingerprint-revalidation pattern as `isPrNoOpFixCausePaused`), and `engine.js` wires `isBuildFixIneffectiveSuppressed()` into the `BUILD_FAILURE` dispatch gate alongside the existing no-op check. A real branch-changing fix clears the stale record. The dashboard PR list (`dashboard/js/render-prs.js`) renders a separate, non-interactive "infra issue suspected" chip for a paused `_buildFixIneffective` record (tooltip shows `reason`/`count`/`headSha`/`liveBuildStatus`) alongside the existing `_pausedCauses` chips.
87
+
84
88
  ## 5. Fix completes
85
89
 
86
90
  - `updatePrAfterFix()` (lifecycle.js) sets `reviewStatus = 'waiting'` + `fixedAt = ts()`
@@ -163,3 +167,4 @@ The exported `recordPrNoOpFixAttempt` / `clearPrNoOpFixAttempt` / `sweepStalePrN
163
167
  - Builds use the merge commit hash as `sourceVersion`, not the source branch commit — compare against `lastMergeCommit.commitId`
164
168
  - `partiallySucceeded` counts as passing (warnings, not failures)
165
169
  - A stale but passing build is still valid — don't re-trigger builds that already passed
170
+ - ADO never auto-clears a reviewer's numeric vote when new commits land, so a hard-reject `-10` vote stays live forever unless the reviewer explicitly re-votes. Both `pollPrStatus()` and the pre-dispatch `checkLiveReviewStatus()` treat a `-10` as **stale/superseded** (resolving to `waiting` instead of `changes-requested`) via `isStaleAdoRejectVote()` once a fix has completed after the vote's last review (`fixedAt > lastReviewedAt`) with no push since (`lastPushedAt <= fixedAt`) — otherwise the auto re-review-after-fix flow can never fire for that PR (issue #633).
@@ -48,6 +48,8 @@ Filenames follow the `YYYY-MM-DD-<agent>-<slug>.md` convention so chronological
48
48
 
49
49
  Agents read `knowledge/` directly with grep/glob/view. They **do not** write to it. See [Sweep-write-only constraint](#sweep-write-only-constraint) below.
50
50
 
51
+ **Full-body copies vs. condensed stubs.** When `engine/consolidation.js#classifyToKnowledgeBase` promotes an inbox note into `knowledge/`, it only copies the full body when the note is routed to `conventions/` or its content carries a reusable-knowledge section header (`## Patterns`, `## Conventions`, `## Gotchas`, `## Dependencies`, `## Best Practices`, etc.). Narrowly-scoped, one-off notes (a single PR review, a task status update) instead get a deterministic, LLM-free condensed stub — title + first-paragraph abstract + a link back to the archived inbox note — to avoid duplicating disk usage and prompt-token cost on every future dispatch. Every promoted entry's frontmatter gains a `reusable: true|false` field so this can be audited (issue #604).
52
+
51
53
  ### 3. `notes.md` — consolidated team notes
52
54
 
53
55
  `notes.md` is the rolling team notebook. The consolidation sweep periodically merges high-signal findings from the inbox into dated `### YYYY-MM-DD` sections at the top of `notes.md`, with links back to the underlying KB entries.
package/docs/watches.md CHANGED
@@ -169,7 +169,7 @@ I/O happens **outside the lock**: notifications via `writeToInbox`, follow-up ac
169
169
  | `dispatch-work-item` | Append a new WI to the project (or central) `work-items.json` with `createdBy: "watch:<id>"` |
170
170
  | `run-skill` | Wrapper around `dispatch-work-item` that asks the agent to run a specific `.claude` skill |
171
171
  | `webhook` | `http`/`https` request to an arbitrary URL (10s safety timeout, JSON or string body) |
172
- | `minions-api` | Loopback call to the in-process dashboard at `http://127.0.0.1:${MINIONS_PORT \|\| 7331}` — `endpoint` must start with `/api/`; sets `X-Minions-Internal: 1`; returns `{ok, status, summary, response}` for chain-step templating |
172
+ | `minions-api` | Loopback call to the in-process dashboard at `http://127.0.0.1:${shared.readDashboardPortFile(MINIONS_DIR)?.port \|\| 7331}` (reads the bound-port beacon file, not the `MINIONS_PORT` env, so it still resolves after an `EADDRINUSE` port scan) — `endpoint` must start with `/api/`; sets `X-Minions-Internal: 1`; returns `{ok, status, summary, response}` for chain-step templating |
173
173
  | `cancel-work-item` | Flip a WI to `WI_STATUS.CANCELLED` across all known work-items files |
174
174
  | `trigger-pipeline` | Start a new pipeline run (skipped if the pipeline already has an active run) |
175
175
  | `archive-plan` | Set PRD `status="archived"` + `archivedAt` |
package/engine/cleanup.js CHANGED
@@ -736,13 +736,26 @@ async function runCleanup(config, verbose = false) {
736
736
  // claims it.
737
737
  if (shared.isWorktreePathLive(full)) {
738
738
  log('info', `Cleanup: skip orphan worktree dir ${full} — live dispatch claims it`);
739
- shared._writeWorktreeSkipLiveInboxNote(full, 'cleanup.orphanWorktreeDirSweep');
739
+ const blockingInfo = shared.getWorktreeBlockingDispatchInfo(full);
740
+ shared._writeWorktreeSkipLiveInboxNote(full, 'cleanup.orphanWorktreeDirSweep', blockingInfo);
740
741
  continue;
741
742
  }
743
+ // W-mr2zu0hb000f20c7 — route through shared.removeWorktree instead of a
744
+ // raw fs.rmSync so this path gets the same retry-with-backoff (transient
745
+ // Windows EBUSY/locked-file handling) and real-repo-refusal safety net
746
+ // that every other worktree removal in this file uses. This dir already
747
+ // isn't registered in `git worktree list`, so the primary
748
+ // `git worktree remove --force` inside removeWorktree is expected to
749
+ // fail and fall through to its fs.rmSync + retry logic — that's fine,
750
+ // the safety checks (ownership marker already verified above; real-repo
751
+ // and live-dispatch refusals re-checked inside removeWorktree) still apply.
742
752
  try {
743
- fs.rmSync(full, { recursive: true, force: true });
744
- cleaned.orphanWorktreeDirs++;
745
- log('info', `Cleanup: removed orphan worktree dir ${full} (not registered in git)`);
753
+ if (shared.removeWorktree(full, root, wtRoot)) {
754
+ cleaned.orphanWorktreeDirs++;
755
+ log('info', `Cleanup: removed orphan worktree dir ${full} (not registered in git)`);
756
+ } else {
757
+ log('warn', `orphan worktree dir ${full}: removeWorktree declined (see prior log line for reason)`);
758
+ }
746
759
  } catch (err) {
747
760
  log('warn', `orphan worktree dir ${full}: ${err.message}`);
748
761
  }
@@ -2707,10 +2707,27 @@ function recordBuildFixIneffective(target, dispatchItem, config, liveBuildStatus
2707
2707
  const paused = count >= pauseAfter;
2708
2708
  const flippedToPaused = paused && !(sameHead && prior?.paused === true);
2709
2709
  const now = ts();
2710
- const reason = `build-fix reported no branch change but live CI is still ${liveBuildStatus || 'failing'} on head ${String(headSha || '').slice(0, 12)}`;
2710
+ // Human-teammate follow-up on #639/#640: once the automation is actually
2711
+ // PAUSED here (multiple ineffective attempts against the same unchanged
2712
+ // head, with live CI re-confirmed still red), a generic "no-op" label
2713
+ // undersells what's going on — repeated fixes aren't landing, which is a
2714
+ // much stronger signal of an environment/CI infra problem than a code
2715
+ // issue. `label` is the short, canonical diagnostic tag; it stays `null`
2716
+ // while attempts are still accumulating (not yet paused) so callers can
2717
+ // tell "still trying" apart from "give up on code fixes, check infra".
2718
+ // Surfaced on every consumer of this record: the PR record field itself
2719
+ // (`label`, plus `reason` below), the pause inbox alert, and the dashboard
2720
+ // chip (dashboard/js/render-prs.js already renders "infra issue suspected"
2721
+ // for `paused === true` — kept in sync with this same wording here).
2722
+ const label = paused ? 'infra issue suspected' : null;
2723
+ const shortHead = String(headSha || '').slice(0, 12);
2724
+ const reason = paused
2725
+ ? `infra issue suspected — build-fix reported no branch change but live CI is still ${liveBuildStatus || 'failing'} on head ${shortHead} after ${count} consecutive ineffective attempt(s)`
2726
+ : `build-fix reported no branch change but live CI is still ${liveBuildStatus || 'failing'} on head ${shortHead}`;
2711
2727
  target._buildFixIneffective = {
2712
2728
  count,
2713
2729
  paused,
2730
+ label,
2714
2731
  headSha,
2715
2732
  liveBuildStatus: liveBuildStatus || 'failing',
2716
2733
  firstAt: sameHead ? (prior.firstAt || now) : now,
@@ -2721,13 +2738,14 @@ function recordBuildFixIneffective(target, dispatchItem, config, liveBuildStatus
2721
2738
  if (flippedToPaused) {
2722
2739
  try {
2723
2740
  const prNumber = target.prNumber || shared.getPrNumber(target) || target.id || 'unknown';
2724
- const noteBody = `# Build fix ineffective: ${target.id || prNumber}\n\n`
2741
+ const noteBody = `# Build fix ineffective — infra issue suspected: ${target.id || prNumber}\n\n`
2725
2742
  + `**PR:** ${target.url || target.id || '(unknown)'}\n`
2726
2743
  + `**Branch:** ${target.branch || '(unknown)'}\n`
2727
2744
  + `**Head:** ${String(headSha || '').slice(0, 40) || '(unknown)'}\n`
2728
2745
  + `**Attempt:** ${count}/${pauseAfter} (paused)\n\n`
2729
2746
  + `A build-failure fix dispatch reported no branch change, but re-polling live CI showed the build is STILL \`${liveBuildStatus || 'failing'}\` against that exact commit. `
2730
2747
  + `The engine reached \`engine.prNoOpFixPauseAttempts\` (${pauseAfter}) consecutive ineffective-fix attempts against the same head and has paused build-failure automation for this PR.\n\n`
2748
+ + `**Infra issue suspected:** ${count} fix dispatches in a row landed on this head without moving it, and CI is still red. This pattern usually means the failure isn't fixable by another automated code-fix pass (flaky infra, an external outage, a broken pipeline/runner, a protected-branch or permissions issue, etc.) rather than a code bug — investigate the CI environment before re-dispatching.\n\n`
2731
2749
  + `**Recovery options:**\n`
2732
2750
  + `1. A new head SHA on this PR auto-clears the pause (a real fix attempt landed).\n`
2733
2751
  + `2. Manually inspect the live CI logs — the automated fixes may be addressing the wrong failure, or the failure may not be fixable by re-pushing (flaky infra, protected branch, etc).\n`;
@@ -3103,7 +3121,7 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
3103
3121
  if (cause === shared.PR_FIX_CAUSE.BUILD_FAILURE && options.buildStillFailing) {
3104
3122
  const record = recordBuildFixIneffective(target, options.dispatchItem, options.config, options.buildStillFailing);
3105
3123
  result = { noOp: true, cause, fixIneffective: true, paused: !!record.paused, count: record.count };
3106
- log('warn', `Updated ${pr.id} → build-fix reported no branch change, but live CI is still ${options.buildStillFailing} on the same head; recorded fixIneffective attempt ${record.count}${record.paused ? ' (paused)' : ''}`);
3124
+ log('warn', `Updated ${pr.id} → build-fix reported no branch change, but live CI is still ${options.buildStillFailing} on the same head; recorded fixIneffective attempt ${record.count}${record.paused ? ' (paused — infra issue suspected)' : ''}`);
3107
3125
  return prs;
3108
3126
  }
3109
3127
  const record = recordPrNoOpFixAttempt(target, cause, source, options.dispatchItem, options.branchChange, options.config, options.noopReason);
package/engine/queries.js CHANGED
@@ -11,7 +11,7 @@ const os = require('os');
11
11
  const shared = require('./shared');
12
12
  const steering = require('./steering');
13
13
 
14
- const { safeRead, safeReadDir, safeJson, safeWrite, getProjects, mutateJsonFileLocked,
14
+ const { safeRead, safeReadDir, safeJson, safeJsonObj, safeWrite, getProjects, mutateJsonFileLocked,
15
15
  projectWorkItemsPath, projectPrPath, parseSkillFrontmatter, KB_CATEGORIES,
16
16
  WI_STATUS, DONE_STATUSES, WORK_TYPE, PRD_ITEM_STATUS, PR_STATUS, ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS } = shared;
17
17
 
@@ -221,7 +221,7 @@ function migrateDeprecatedConfigPollKeysOnce() {
221
221
 
222
222
  function getConfig() {
223
223
  migrateDeprecatedConfigPollKeysOnce();
224
- return safeJson(CONFIG_PATH) || {};
224
+ return safeJsonObj(CONFIG_PATH);
225
225
  }
226
226
 
227
227
  function getControl() {
package/engine/shared.js CHANGED
@@ -8776,11 +8776,60 @@ function isWorktreePathLive(worktreePath, opts = {}) {
8776
8776
  return false;
8777
8777
  }
8778
8778
 
8779
+ // P-1d6b1aa7 — separate, best-effort lookup of the specific dispatch row
8780
+ // that is blocking a wipe, so callers can fold self-diagnosing detail (id,
8781
+ // status, age-in-non-terminal-state) into the skip-live inbox note instead
8782
+ // of forcing a human to run a follow-up SQL query against
8783
+ // engine/state.db every time W-mq5rwwss000f30a7 recurs. Deliberately kept
8784
+ // separate from isWorktreePathLive so that helper's fail-open boolean
8785
+ // contract (and its existing callers/tests) are untouched — this is an
8786
+ // additional, purely-diagnostic query run only after the guard has already
8787
+ // fired. Any failure here (SQL unavailable, no matching row, etc.) resolves
8788
+ // to `null` and the note falls back to an "unavailable" label; it must
8789
+ // never throw or change wipe/skip behavior.
8790
+ function getWorktreeBlockingDispatchInfo(worktreePath, opts = {}) {
8791
+ try {
8792
+ const target = _normalizeWorktreePath(worktreePath);
8793
+ if (!target) return null;
8794
+ const excludeDispatchId = opts.excludeDispatchId ? String(opts.excludeDispatchId) : null;
8795
+ let db = opts.db || null;
8796
+ if (!db) {
8797
+ try { db = require('./db').getDb(); }
8798
+ catch { return null; }
8799
+ }
8800
+ if (!db) return null;
8801
+ const rows = db.prepare(`
8802
+ SELECT id, status, updated_at,
8803
+ json_extract(data, '$.worktreePath') AS top_wt,
8804
+ json_extract(data, '$.meta.worktreePath') AS meta_wt
8805
+ FROM dispatches
8806
+ WHERE status IN ('pending', 'active')
8807
+ `).all();
8808
+ for (const row of rows || []) {
8809
+ if (excludeDispatchId && String(row.id) === excludeDispatchId) continue;
8810
+ const topMatch = row.top_wt && _normalizeWorktreePath(row.top_wt) === target;
8811
+ const metaMatch = row.meta_wt && _normalizeWorktreePath(row.meta_wt) === target;
8812
+ if (topMatch || metaMatch) {
8813
+ const updatedAt = Number(row.updated_at) || 0;
8814
+ const ageMs = updatedAt > 0 ? Math.max(0, Date.now() - updatedAt) : null;
8815
+ return { id: row.id, status: row.status, ageMs };
8816
+ }
8817
+ }
8818
+ return null;
8819
+ } catch {
8820
+ return null; // best-effort diagnostics only — never throw
8821
+ }
8822
+ }
8823
+
8779
8824
  // Drop a deduped inbox note when a wipe site skips due to the live guard so
8780
8825
  // operators can see when the guard fires. Filename is keyed on basename +
8781
8826
  // UTC date — a single skip per worktree per day produces one note; further
8782
- // skips that day silently no-op.
8783
- function _writeWorktreeSkipLiveInboxNote(worktreePath, callerTag) {
8827
+ // skips that day silently no-op. `blockingInfo` (optional, shape
8828
+ // `{ id, status, ageMs }` from getWorktreeBlockingDispatchInfo) folds the
8829
+ // blocking dispatch's diagnostics into the note body so it is
8830
+ // self-diagnosing; when unavailable (no matching row, SQL unreachable,
8831
+ // etc.) the note says so explicitly instead of requiring a follow-up query.
8832
+ function _writeWorktreeSkipLiveInboxNote(worktreePath, callerTag, blockingInfo) {
8784
8833
  try {
8785
8834
  const base = path.basename(String(worktreePath || '').replace(/[\\/]+$/g, '')) || 'unknown';
8786
8835
  const safeBase = base.replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 80);
@@ -8790,6 +8839,13 @@ function _writeWorktreeSkipLiveInboxNote(worktreePath, callerTag) {
8790
8839
  try { fs.mkdirSync(inboxDir, { recursive: true }); } catch { /* exists */ }
8791
8840
  const fpath = path.join(inboxDir, fname);
8792
8841
  if (fs.existsSync(fpath)) return; // deduped
8842
+ const diagnosticsLines = (blockingInfo && blockingInfo.id)
8843
+ ? [
8844
+ `- blocking dispatch id: ${blockingInfo.id}`,
8845
+ `- blocking dispatch status: ${blockingInfo.status || 'unknown'}`,
8846
+ `- blocking dispatch age: ${blockingInfo.ageMs != null ? `${Math.round(blockingInfo.ageMs / 1000)}s since last update` : 'unavailable'}`,
8847
+ ]
8848
+ : ['- blocking dispatch diagnostics: unavailable (no matching row found, or SQL was unreachable)'];
8793
8849
  const body = [
8794
8850
  '---',
8795
8851
  `id: NOTE-${crypto.randomBytes(8).toString('hex')}`,
@@ -8802,6 +8858,7 @@ function _writeWorktreeSkipLiveInboxNote(worktreePath, callerTag) {
8802
8858
  `- caller: ${callerTag || 'unknown'}`,
8803
8859
  `- worktree: ${worktreePath}`,
8804
8860
  `- timestamp: ${new Date().toISOString()}`,
8861
+ ...diagnosticsLines,
8805
8862
  '',
8806
8863
  'A non-terminal dispatch row still claims this worktree. The wipe was skipped to',
8807
8864
  'protect agent state. If this fires repeatedly, inspect engine/state.db dispatches',
@@ -8881,7 +8938,8 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot, opts = {}) {
8881
8938
  const excludeDispatchId = opts && opts.excludeDispatchId ? String(opts.excludeDispatchId) : null;
8882
8939
  if (isWorktreePathLive(resolved, excludeDispatchId ? { excludeDispatchId } : undefined)) {
8883
8940
  log('warn', `removeWorktree: skip — live dispatch in ${wtPath}`);
8884
- _writeWorktreeSkipLiveInboxNote(wtPath, 'shared.removeWorktree');
8941
+ const blockingInfo = getWorktreeBlockingDispatchInfo(resolved, excludeDispatchId ? { excludeDispatchId } : undefined);
8942
+ _writeWorktreeSkipLiveInboxNote(wtPath, 'shared.removeWorktree', blockingInfo);
8885
8943
  return false;
8886
8944
  }
8887
8945
  _pruneRemoveWorktreeFailures();
@@ -9496,6 +9554,7 @@ module.exports = {
9496
9554
  listProcessReachable,
9497
9555
  removeWorktree,
9498
9556
  isWorktreePathLive,
9557
+ getWorktreeBlockingDispatchInfo,
9499
9558
  WORKTREE_OWNER_MARKER,
9500
9559
  writeWorktreeOwnerMarker,
9501
9560
  hasWorktreeOwnerMarker,
package/engine.js CHANGED
@@ -1697,7 +1697,8 @@ async function _quarantineDirtyWorktree(rootDir, worktreePath, branchName, gitOp
1697
1697
  // agent's git child processes either.
1698
1698
  if (shared.isWorktreePathLive(worktreePath)) {
1699
1699
  log('warn', `_quarantineDirtyWorktree: skip — live dispatch in ${worktreePath}`);
1700
- shared._writeWorktreeSkipLiveInboxNote(worktreePath, '_quarantineDirtyWorktree');
1700
+ const blockingInfo = shared.getWorktreeBlockingDispatchInfo(worktreePath);
1701
+ shared._writeWorktreeSkipLiveInboxNote(worktreePath, '_quarantineDirtyWorktree', blockingInfo);
1701
1702
  return { quarantinedPath: null, backupRef: null, skipped: true };
1702
1703
  }
1703
1704
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2313",
3
+ "version": "0.1.2315",
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"