@yemi33/minions 0.1.2380 → 0.1.2382

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dashboard.js CHANGED
@@ -421,8 +421,14 @@ function mergeSettingsConfigUpdate(current, candidate, body, patch = {}) {
421
421
  // `candidate` in handleSettingsUpdate) is silently dropped on the way
422
422
  // through mergeSettingsConfigUpdate → mutateDashboardConfig and never
423
423
  // reaches disk — the endpoint would return 200 but persist nothing.
424
+ const modeWasRequested = Object.prototype.hasOwnProperty.call(update, 'checkoutMode')
425
+ || Object.prototype.hasOwnProperty.call(update, 'worktreeMode');
424
426
  if (Object.prototype.hasOwnProperty.call(candidateProject, 'checkoutMode')) {
425
427
  currentProject.checkoutMode = candidateProject.checkoutMode;
428
+ } else if (Object.prototype.hasOwnProperty.call(candidateProject, 'worktreeMode')) {
429
+ currentProject.checkoutMode = shared.resolveCheckoutMode(candidateProject);
430
+ } else if (!modeWasRequested && Object.prototype.hasOwnProperty.call(currentProject, 'worktreeMode')) {
431
+ currentProject.checkoutMode = shared.resolveCheckoutMode(currentProject);
426
432
  } else {
427
433
  delete currentProject.checkoutMode;
428
434
  }
@@ -832,7 +838,7 @@ async function copyWorkItemPrFields(item, input, pr = null, opts = {}) {
832
838
  if (structuredRef) {
833
839
  prRef = structuredRef; // explicit operator intent — trusted, no verification
834
840
  } else {
835
- if (String(item?.type || '').toLowerCase() !== WORK_TYPE.FIX) return;
841
+ if (!shared.isFixLikeWorkType(String(item?.type || '').toLowerCase())) return;
836
842
  const looseRef = getWorkItemPrRef(input);
837
843
  if (!looseRef) return;
838
844
  // issue #246 — confirm the loose ref is a PR (not an issue) before stamping.
@@ -1649,13 +1655,23 @@ const PLANS_DIR = path.join(MINIONS_DIR, 'plans');
1649
1655
  // W-mpetru8a000s123a — /api/plans cache. Dashboard auto-polls every 4s; the
1650
1656
  // pre-cache cold path walked 4 directories (plans/, prd/, archive variants),
1651
1657
  // sync-stat'd every file, and parsed/regex-scanned each .md → 2-3s blocking.
1652
- // 5s TTL means external .md edits surface within 5s. Mutation handlers must
1653
- // call invalidatePlansCache() for immediate visibility.
1658
+ // 30s TTL keeps the full markdown/SQL rollup off the 4s browser poll cadence.
1659
+ // Mutation handlers call invalidatePlansCache() for immediate visibility;
1660
+ // out-of-process edits surface within 30s.
1654
1661
  let _plansCache = null;
1655
1662
  let _plansCacheTs = 0;
1656
- const PLANS_CACHE_TTL_MS = 5000;
1663
+ const PLANS_CACHE_TTL_MS = 30000;
1657
1664
  function invalidatePlansCache() { _plansCache = null; _plansCacheTs = 0; }
1658
1665
 
1666
+ async function _mapInBatches(items, batchSize, mapper) {
1667
+ const result = [];
1668
+ for (let start = 0; start < items.length; start += batchSize) {
1669
+ result.push(...await Promise.all(items.slice(start, start + batchSize).map(mapper)));
1670
+ if (start + batchSize < items.length) await _yieldEventLoop();
1671
+ }
1672
+ return result;
1673
+ }
1674
+
1659
1675
  function statePathExists(filePath) {
1660
1676
  return fs.existsSync(filePath);
1661
1677
  }
@@ -2538,26 +2554,7 @@ function _buildStatusSlowState() {
2538
2554
  ...status,
2539
2555
  mainBranch,
2540
2556
  branchMismatch,
2541
- // P-a3f9b209 / W-mqgzcrln002613b3 — surface the per-project dispatch
2542
- // mode so the Projects view can render a "Worktrees" vs "Live checkout"
2543
- // pill. resolveCheckoutMode honors the legacy worktreeMode field and
2544
- // defaults to 'worktree' when unset (matches engine spawn behavior).
2545
- checkoutMode: shared.resolveCheckoutMode(p),
2546
- // Surface the hybrid live-validation type (when configured) so the
2547
- // Projects view can render a distinct "Hybrid" pill: coding WIs author
2548
- // in isolated worktrees while only the named validation type runs
2549
- // in-place on the live checkout. Only meaningful when checkoutMode is
2550
- // 'live'; null otherwise (full-live or worktree projects).
2551
- liveValidationType: (shared.resolveCheckoutMode(p) === 'live' && p.liveValidation && p.liveValidation.type)
2552
- ? p.liveValidation.type
2553
- : null,
2554
- // W-mr2q361a00097e5c — surface liveValidation.autoDispatch alongside
2555
- // .type so the hybrid picker can preselect the "auto-validate"
2556
- // checkbox and the pill can visibly distinguish manual vs
2557
- // auto-validating hybrid projects. Only meaningful when checkoutMode
2558
- // is 'live'; false otherwise (matches the implicit today-default of
2559
- // autoDispatch being absent/false).
2560
- liveValidationAutoDispatch: !!(shared.resolveCheckoutMode(p) === 'live' && p.liveValidation && p.liveValidation.autoDispatch === true),
2557
+ ..._projectCheckoutConfigView(p),
2561
2558
  };
2562
2559
  }),
2563
2560
  autoMode: {
@@ -2974,15 +2971,35 @@ async function _freshnessEntryMtimeMs(fp, depth) {
2974
2971
  return max;
2975
2972
  }
2976
2973
 
2974
+ const FRESHNESS_MTIME_CACHE_TTL_MS = 1000;
2975
+ const _freshnessMtimeCache = new Map();
2976
+
2977
2977
  async function _maxInputMtimeMs(inputs) {
2978
+ const key = inputs.map(fp => path.resolve(fp)).sort().join('\0');
2979
+ const now = Date.now();
2980
+ const cached = _freshnessMtimeCache.get(key);
2981
+ if (cached && now - cached.createdAt < FRESHNESS_MTIME_CACHE_TTL_MS) {
2982
+ return cached.promise;
2983
+ }
2978
2984
  // Directory mtime only advances on add/remove, not in-place edits. Walk two
2979
2985
  // levels so prd/<plan>.json and agents/<id>/live-output.log both bust ETags.
2980
2986
  // Deeper steering edits still rely on add/remove cadence, matching the prior
2981
2987
  // bounded walk.
2982
- const mtimes = await Promise.all(
2988
+ const promise = Promise.all(
2983
2989
  inputs.map(fp => _freshnessEntryMtimeMs(fp, 2)),
2984
- );
2985
- return Math.floor(mtimes.reduce((max, mtime) => Math.max(max, mtime), 0));
2990
+ ).then(mtimes => Math.floor(mtimes.reduce((max, mtime) => Math.max(max, mtime), 0)));
2991
+ _freshnessMtimeCache.set(key, { createdAt: now, promise });
2992
+ if (_freshnessMtimeCache.size > 100) {
2993
+ for (const [cacheKey, entry] of _freshnessMtimeCache) {
2994
+ if (now - entry.createdAt >= FRESHNESS_MTIME_CACHE_TTL_MS) _freshnessMtimeCache.delete(cacheKey);
2995
+ }
2996
+ }
2997
+ try {
2998
+ return await promise;
2999
+ } catch (err) {
3000
+ if (_freshnessMtimeCache.get(key)?.promise === promise) _freshnessMtimeCache.delete(key);
3001
+ throw err;
3002
+ }
2986
3003
  }
2987
3004
  async function serveFreshJson(req, res, opts) {
2988
3005
  const inputs = Array.isArray(opts && opts.inputs) ? opts.inputs : [];
@@ -3174,7 +3191,20 @@ function handleStateRead(req, res) {
3174
3191
  res.setHeader('Content-Type', _stateReadContentType(path.extname(resolved)));
3175
3192
  try {
3176
3193
  const stream = fs.createReadStream(resolved);
3194
+ // HANDLE-LEAK GUARD (#858): destroy the read stream if the client aborts or
3195
+ // the connection closes before the pipe drains. Without this, an aborted
3196
+ // GET /state/notes.md leaks the open file handle indefinitely (confirmed
3197
+ // via handle64: dashboard held a handle on notes.md for 2+ hours). On
3198
+ // Windows that lingering handle makes the engine's consolidation
3199
+ // renameSync throw EPERM, which used to crash-loop the whole daemon.
3200
+ const destroyStream = () => { if (!stream.destroyed) stream.destroy(); };
3201
+ if (typeof res.on === 'function') res.on('close', destroyStream);
3202
+ if (typeof req.on === 'function') {
3203
+ req.on('close', destroyStream);
3204
+ req.on('aborted', destroyStream);
3205
+ }
3177
3206
  stream.on('error', (err) => {
3207
+ destroyStream();
3178
3208
  if (!res.headersSent) {
3179
3209
  res.statusCode = 500;
3180
3210
  res.setHeader('Content-Type', 'application/json');
@@ -4113,20 +4143,40 @@ function _resetPreambleCache() {
4113
4143
  _preambleCacheTs = 0;
4114
4144
  }
4115
4145
 
4116
- // Compact one-word-ish label for a project's effective dispatch checkout mode,
4117
- // for CC state surfaces (preamble + refresh). 'worktree' | 'live' |
4118
- // 'hybrid (live-validation: <type>)' so CC can see at a glance whether a project
4119
- // authors in isolated worktrees, runs in-place on the operator tree, or splits
4120
- // the two (coding → worktree, validation type → live). See resolveCheckoutMode.
4146
+ function _projectLiveValidationTypeValue(p) {
4147
+ const types = shared.resolveLiveValidationTypes(p);
4148
+ if (types.length === 0) return null;
4149
+ return Array.isArray(p?.liveValidation?.type) ? types : types[0];
4150
+ }
4151
+
4152
+ function _projectCheckoutConfigView(p) {
4153
+ const checkoutMode = shared.resolveCheckoutMode(p);
4154
+ return {
4155
+ checkoutMode,
4156
+ liveValidationType: checkoutMode === 'live' ? _projectLiveValidationTypeValue(p) : null,
4157
+ liveValidationInvalid: checkoutMode === 'live' && shared.isLiveValidationConfigInvalid(p),
4158
+ liveValidationAutoDispatch: !!shared.resolveLiveValidationAutoDispatchType(p),
4159
+ };
4160
+ }
4161
+
4162
+ // Compact checkout-mode label for Command Center state surfaces.
4121
4163
  function _projectCheckoutModeLabel(p) {
4122
- const mode = shared.resolveCheckoutMode(p);
4123
- if (mode === 'live' && p && p.liveValidation && p.liveValidation.type) {
4124
- // liveValidation.type may be a single string (legacy) or an array of
4125
- // strings (W-mr2m1ute000a9c01 — multi-select hybrid types); render all.
4126
- const types = Array.isArray(p.liveValidation.type) ? p.liveValidation.type : [p.liveValidation.type];
4164
+ const {
4165
+ checkoutMode,
4166
+ liveValidationType,
4167
+ liveValidationInvalid,
4168
+ } = _projectCheckoutConfigView(p);
4169
+ if (liveValidationInvalid) {
4170
+ const effectiveTypes = liveValidationType
4171
+ ? (Array.isArray(liveValidationType) ? liveValidationType : [liveValidationType])
4172
+ : [];
4173
+ return `hybrid (invalid liveValidation; effective live types: ${effectiveTypes.join(', ') || 'none'})`;
4174
+ }
4175
+ if (checkoutMode === 'live' && liveValidationType) {
4176
+ const types = Array.isArray(liveValidationType) ? liveValidationType : [liveValidationType];
4127
4177
  return `hybrid (live-validation: ${types.join(', ')})`;
4128
4178
  }
4129
- return mode;
4179
+ return checkoutMode;
4130
4180
  }
4131
4181
 
4132
4182
  function buildCCStatePreamble() {
@@ -4299,13 +4349,13 @@ function getWorkItemPrRef(input) {
4299
4349
  // afford to be loose because gating would still require explicit
4300
4350
  // operator intent.
4301
4351
  //
4302
- // FIX-TYPE GATE on the stamp path (W-mqbaby2a000pa8ee): in practice
4352
+ // FIX-LIKE GATE on the stamp path (W-mqbaby2a000pa8ee): in practice
4303
4353
  // the asymmetry leaked — the loose stamp writes to `item.pr_id` (a
4304
4354
  // canonical structured field), and the strict gate later reads
4305
4355
  // `item.pr_id` and sees the loose stamp AS IF it were structured
4306
4356
  // intent. To stop that leak without changing the loose detector
4307
4357
  // (which has other legitimate callers), `copyWorkItemPrFields` now
4308
- // gates the loose result on `item.type === "fix"`. Non-fix WIs only
4358
+ // gates the loose result through `isFixLikeWorkType`. Other WIs only
4309
4359
  // get stamped from structured fields (incl. references/follow-up).
4310
4360
  // See the comment block on `copyWorkItemPrFields` above for details.
4311
4361
  return shared.extractWorkItemPrRef(input);
@@ -7681,7 +7731,7 @@ const server = http.createServer(async (req, res) => {
7681
7731
  { dir: path.join(PLANS_DIR, 'archive'), archived: true },
7682
7732
  ]) {
7683
7733
  const allFiles = (await fsp.readdir(dir).catch(() => [])).filter(f => f.endsWith('.md'));
7684
- const dirResults = await Promise.all(allFiles.map(async f => {
7734
+ const dirResults = await _mapInBatches(allFiles, 25, async f => {
7685
7735
  const filePath = path.join(dir, f);
7686
7736
  const [content, stat] = await Promise.all([
7687
7737
  fsp.readFile(filePath, 'utf8').catch(() => ''),
@@ -7718,10 +7768,20 @@ const server = http.createServer(async (req, res) => {
7718
7768
  revisionFeedback: null,
7719
7769
  version: versionMatch ? parseInt(versionMatch[1]) : null,
7720
7770
  };
7721
- }));
7771
+ });
7722
7772
  for (const r of dirResults) if (r) plans.push(r);
7723
7773
  }
7724
7774
 
7775
+ const sourcePlanMtimes = new Map();
7776
+ const sourcePlanNames = [...new Set(prdRows
7777
+ .filter(row => !row.archived && row.plan?.source_plan)
7778
+ .map(row => row.plan.source_plan))];
7779
+ const sourceStats = await _mapInBatches(sourcePlanNames, 25, async sourcePlan => {
7780
+ const stat = await fsp.stat(path.join(PLANS_DIR, sourcePlan)).catch(() => null);
7781
+ return [sourcePlan, stat ? Math.floor(stat.mtimeMs) : null];
7782
+ });
7783
+ for (const [sourcePlan, mtimeMs] of sourceStats) sourcePlanMtimes.set(sourcePlan, mtimeMs);
7784
+
7725
7785
  // --- PRD JSON records (prd/*.json + prd/archive/*.json, SQL-backed) ---
7726
7786
  for (const row of prdRows) {
7727
7787
  const plan = row.plan;
@@ -7735,11 +7795,9 @@ const server = http.createServer(async (req, res) => {
7735
7795
  // so the Plans tab does not silently mirror a stale-but-cached false.
7736
7796
  let freshStale = false;
7737
7797
  if (!archived && plan.source_plan) {
7738
- try {
7739
- const sourceMtime = Math.floor(fs.statSync(path.join(PLANS_DIR, plan.source_plan)).mtimeMs);
7740
- const recorded = plan.sourcePlanModifiedAt ? new Date(plan.sourcePlanModifiedAt).getTime() : null;
7741
- if (recorded && sourceMtime > recorded) freshStale = true;
7742
- } catch { /* source plan may have been deleted/renamed */ }
7798
+ const sourceMtime = sourcePlanMtimes.get(plan.source_plan);
7799
+ const recorded = plan.sourcePlanModifiedAt ? new Date(plan.sourcePlanModifiedAt).getTime() : null;
7800
+ if (recorded && sourceMtime != null && sourceMtime > recorded) freshStale = true;
7743
7801
  }
7744
7802
  // P-e8d49105 — _projects rollup feeds the plan-card multi-badge
7745
7803
  // path in dashboard/js/render-plans.js. For PRD JSONs, walk
@@ -10835,19 +10893,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10835
10893
  name: p.name,
10836
10894
  localPath: p.localPath || null,
10837
10895
  mainBranch: p.mainBranch || null,
10838
- // P-a3f9b207 — surface checkoutMode so the Settings UI can pre-fill
10839
- // the per-project dropdown. resolveCheckoutMode honors the legacy
10840
- // worktreeMode field; 'worktree' (default) or 'live'.
10841
- checkoutMode: shared.resolveCheckoutMode(p),
10842
- // W-mr9mx93d000x1dda — surface the hybrid liveValidation config
10843
- // (type(s) + autoDispatch) so the Settings → Projects checkout-mode
10844
- // control can pre-fill its Hybrid sub-panel authoritatively (mirrors
10845
- // the /api/status project mapping the Projects-bar picker reads).
10846
- // Only meaningful under checkoutMode:'live'; null/false otherwise.
10847
- liveValidationType: (shared.resolveCheckoutMode(p) === 'live' && p.liveValidation && p.liveValidation.type)
10848
- ? p.liveValidation.type
10849
- : null,
10850
- liveValidationAutoDispatch: !!(shared.resolveCheckoutMode(p) === 'live' && p.liveValidation && p.liveValidation.autoDispatch === true),
10896
+ ..._projectCheckoutConfigView(p),
10851
10897
  // W-mqtvnnj1000357fa — surface the RAW per-project auto-stash override
10852
10898
  // (true / false / undefined) so the Settings UI can pre-select the
10853
10899
  // tri-state dropdown. Undefined means "use fleet default"
@@ -11767,6 +11813,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11767
11813
  age_minutes: ageMinutes,
11768
11814
  };
11769
11815
  }
11816
+
11770
11817
  return {
11771
11818
  agentId: rec.agentId,
11772
11819
  valid: true,
@@ -11790,6 +11837,18 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11790
11837
  }
11791
11838
  }
11792
11839
 
11840
+ async function handleWorktreeQuarantineRefs(req, res) {
11841
+ try {
11842
+ const { listQuarantineRefs } = require('./engine/quarantine-refs');
11843
+ return jsonReply(res, 200, {
11844
+ items: await listQuarantineRefs(PROJECTS),
11845
+ generatedAt: new Date().toISOString(),
11846
+ }, req);
11847
+ } catch (e) {
11848
+ return jsonReply(res, 500, { error: e.message }, req);
11849
+ }
11850
+ }
11851
+
11793
11852
  async function handleKeepProcessesKill(req, res) {
11794
11853
  try {
11795
11854
  const body = await readBody(req);
@@ -13184,6 +13243,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13184
13243
  }},
13185
13244
  { method: 'GET', path: '/api/health', desc: 'Lightweight health check for monitoring', handler: handleHealth },
13186
13245
  { method: 'GET', path: '/api/keep-processes', desc: 'List all active agents/<id>/keep-pids.json entries (W-mp68q6ke0010de68)', handler: handleKeepProcessesList },
13246
+ { method: 'GET', path: '/api/worktree-quarantine-refs', desc: 'List recoverable refs/minions/quarantine/* and quarantine-wip/* refs across configured projects', handler: handleWorktreeQuarantineRefs },
13187
13247
  { method: 'POST', path: '/api/keep-processes/kill', desc: 'Kill a single kept PID and remove it from the agent\'s keep-pids.json', params: 'agentId, pid', handler: handleKeepProcessesKill },
13188
13248
  // P-4b8d2e57 (managed-spawn item 4): engine-managed long-running services.
13189
13249
  // List/by-name return ETag + honor If-None-Match → 304. Kill removes from
@@ -14772,7 +14832,8 @@ if (require.main === module) {
14772
14832
  // supervisor) discovers it instead of guessing 7331. The original
14773
14833
  // server.on('error') one-shot exit is replaced by the retry loop — only
14774
14834
  // non-EADDRINUSE errors abort.
14775
- const maxScan = shared.DASHBOARD_PORT_SCAN_MAX || 100;
14835
+ const requireRequestedPort = process.env.MINIONS_REQUIRE_DASHBOARD_PORT === '1';
14836
+ const maxScan = requireRequestedPort ? 1 : (shared.DASHBOARD_PORT_SCAN_MAX || 100);
14776
14837
  let listenErr = null;
14777
14838
  for (let attempt = 0; attempt < maxScan; attempt++) {
14778
14839
  const tryPort = REQUESTED_PORT + attempt;
@@ -14798,7 +14859,10 @@ if (require.main === module) {
14798
14859
  }
14799
14860
  if (listenErr) {
14800
14861
  if (listenErr.code === 'EADDRINUSE') {
14801
- console.error(`\n No free port in [${REQUESTED_PORT}..${REQUESTED_PORT + maxScan - 1}]. Free one and retry.\n`);
14862
+ const range = requireRequestedPort
14863
+ ? String(REQUESTED_PORT)
14864
+ : `[${REQUESTED_PORT}..${REQUESTED_PORT + maxScan - 1}]`;
14865
+ console.error(`\n No free dashboard port at ${range}. Free it and retry.\n`);
14802
14866
  } else {
14803
14867
  console.error(listenErr);
14804
14868
  }
package/docs/README.md CHANGED
@@ -29,6 +29,7 @@ Hands-on stories and distribution guides for people running or evaluating Minion
29
29
  Architecture, design proposals, and lifecycle references for people working on the engine, dashboard, or playbooks.
30
30
 
31
31
  - [branch-derivation.md](branch-derivation.md) — Engine-side branch fallback (`work/<wi-id>`) vs. agent-authored long form, the structured-vs-loose PR-pointer extractors, and the canonical PR-fix duplication incident.
32
+ - [claude-md-propagation.md](claude-md-propagation.md) — Propagating repo-authored `CLAUDE.md` instructions to runtimes that don't auto-load it (Copilot/Codex): the `claudeMdNativeDiscovery` capability flag, the bounded nearest-applicable walk-up discovery, the `propagateClaudeMdForNonClaudeRuntimes` config knob, and why it's orthogonal to each runtime's native `AGENTS.md` discovery.
32
33
  - [command-center.md](command-center.md) — Command Center (CC) chat panel: configured-runtime sessions, resume semantics, system-prompt invalidation, and per-tab session storage.
33
34
  - [specs/agent-rename.md](specs/agent-rename.md) — Design spec for **agent rename & name decoupling** — the prerequisite for the Role/Agent Library. Formalizes the stable opaque agent id, makes charters name-agnostic, and adds `POST /api/agents/rename` (display name + emoji). Feature-flagged (`agent-rename`) and fully backward-compatible.
34
35
  - [specs/agent-configurability.md](specs/agent-configurability.md) — Design spec for the user-configurable Role / Agent Library, feature-flagged behind `agent-library` and fully backward-compatible. Builds on `agent-rename.md`. Role = the reusable durable charter; agent = a thin instance (identity + variable expertise + role pointer). Phase 0 renames `skills`→`expertise`; Phase 1 exposes/edits role charters + per-agent expertise; Phase 2 adds role/agent CRUD with builtin delete-protection.
@@ -0,0 +1,118 @@
1
+ # CLAUDE.md propagation to non-Claude runtimes
2
+
3
+ **Work item:** W-mrdon0pe000l045a
4
+ **Config flag:** `engine.propagateClaudeMdForNonClaudeRuntimes` (default `true`)
5
+ **Per-project override:** `project.propagateClaudeMdForNonClaudeRuntimes`
6
+ **Module:** `engine/claude-md-context.js` · **Injection:** `engine/playbook.js`
7
+
8
+ ## Problem
9
+
10
+ `CLAUDE.md` auto-discovery is a **Claude-Code-CLI-only** convention — the `claude`
11
+ binary reads repo-authored `CLAUDE.md` files itself at startup (unless `--bare`).
12
+ The **Copilot** and **Codex** runtimes have **no equivalent auto-load** for
13
+ `CLAUDE.md` at all. So repo-authored `CLAUDE.md` files — which frequently carry
14
+ load-bearing "must be kept in sync" instructions and name blessed tooling — are
15
+ structurally invisible to a Copilot/Codex-based fleet.
16
+
17
+ ### Motivating incident
18
+
19
+ `W-mrdkv6zc00071fef` (a `fluidclientframework.android` dependency bump in
20
+ `office/src`) missed a **third** sync-point file (`settings.gradle.kts:116`) that
21
+ `loop/app/android/android/CLAUDE.md` explicitly warns about, and that
22
+ `updateDeps.bat` (named in that same `CLAUDE.md`) would have caught via its build
23
+ verification step. Neither the implementer nor the reviewer ever saw that file's
24
+ content because Copilot has no `CLAUDE.md` auto-load mechanism. Propagating
25
+ `CLAUDE.md` content directly addresses recurrence of this bug class.
26
+
27
+ ## Relationship to Copilot's native `AGENTS.md` discovery (NOT the same gap)
28
+
29
+ Copilot has its **own native `AGENTS.md` auto-load**, which (post-#817, "Delegate
30
+ repository harness discovery to native runtimes") Minions leaves to the runtime
31
+ CLI itself rather than suppressing or synthesizing.
32
+
33
+ `CLAUDE.md` propagation is **independent and additive**:
34
+
35
+ - `CLAUDE.md` carries **zero split-brain risk** for Copilot/Codex, because those
36
+ runtimes **never read it themselves** — there is nothing to conflict with.
37
+ - Therefore propagation is **orthogonal** to Copilot's native `AGENTS.md`
38
+ discovery: whether or not the runtime loads its own `AGENTS.md` has **no
39
+ effect** on `CLAUDE.md` propagation.
40
+
41
+ | Concern | Owner | What governs it |
42
+ |---------|-------|-----------------|
43
+ | Copilot's OWN `AGENTS.md` auto-load | the Copilot CLI (native) | delegated to the runtime; Minions does not suppress or synthesize it |
44
+ | Repo `CLAUDE.md` invisible to non-Claude runtimes | `propagateClaudeMdForNonClaudeRuntimes` | injects a "Project instructions (CLAUDE.md)" prompt layer |
45
+
46
+ ## Capability flag
47
+
48
+ Each runtime adapter (`engine/runtimes/<name>.js`) declares
49
+ `capabilities.claudeMdNativeDiscovery`:
50
+
51
+ | Runtime | `claudeMdNativeDiscovery` | Propagation |
52
+ |---------|---------------------------|-------------|
53
+ | `claude` | `true` | **skipped** — the Claude CLI already reads CLAUDE.md; re-injecting would double it |
54
+ | `copilot` | `false` | **injected** |
55
+ | `codex` | `false` | **injected** |
56
+
57
+ Unknown runtimes are treated as **not** auto-discovering (propagate), since an
58
+ unrecognized runtime is far more likely to lack native CLAUDE.md loading than to
59
+ have it. Engine code gates on the capability flag, never on `runtime.name`.
60
+
61
+ ## Bounded discovery algorithm
62
+
63
+ `office/src` is a huge monorepo — a full tree walk collecting every `CLAUDE.md`
64
+ is not viable. Instead the module mirrors how a human/tool resolves "nearest
65
+ applicable instructions":
66
+
67
+ 1. **Collect path hints** for the dispatch (`engine.js#_deriveClaudeMdPathHints`):
68
+ `item.meta.workdir` and `item.references[*].path`.
69
+ 2. **Walk UP** from each hint directory toward the **project root**, `stat`-ing
70
+ `CLAUDE.md` at each ancestor level (`discoverClaudeMdFiles`).
71
+ 3. **Always include the project-root `CLAUDE.md`** as a fallback — if there are
72
+ no path hints, at minimum the project root is checked (discovery is never
73
+ skipped entirely).
74
+ 4. **Nearest-wins ordering:** deepest directory first, project root last, so the
75
+ most specific instructions lead.
76
+ 5. **Bounds (cheap by construction):**
77
+ - `MAX_WALK_DEPTH = 24` ancestor levels per hint (deep-path guard),
78
+ - `MAX_FILES = 8` total collected files,
79
+ - path-traversal guard — hints that escape the project root are dropped,
80
+ - the walk **never ascends above the project root**.
81
+
82
+ This only `stat`s `CLAUDE.md` at a small number of ancestor directories, so it
83
+ does not meaningfully slow dispatch even in a monorepo.
84
+
85
+ ## Injection & byte cap
86
+
87
+ `engine/playbook.js#renderPlaybook` adds `CLAUDE.md` propagation as a context
88
+ layer alongside the existing `pinned.md` → `notes.md` →
89
+ `knowledge/agents/<agentId>.md` layers:
90
+
91
+ - Header: `## Project instructions (CLAUDE.md)`, with a short provenance note so
92
+ the agent understands why it appears and that "keep in sync" rules are
93
+ authoritative.
94
+ - Content is wrapped in an `<UNTRUSTED-INPUT>` fence (repo-authored, semi-trusted).
95
+ - Each file is labeled by its repo-relative path (`### <relPath>`).
96
+ - The whole block is capped at `ENGINE_DEFAULTS.maxNotesPromptBytes` (the same
97
+ budget the sibling notes / agent-memory layers use), so it can never balloon a
98
+ prompt on a large monorepo. Individual files are truncated with a
99
+ "read the full file if needed" marker when they exceed the remaining budget.
100
+
101
+ Injection only happens when **all** hold:
102
+
103
+ 1. the resolved runtime's `claudeMdNativeDiscovery` is falsy (Copilot/Codex),
104
+ 2. `resolvePropagateClaudeMdForNonClaudeRuntimes(project, engine)` returns true,
105
+ 3. the project has a `localPath`, and
106
+ 4. at least one non-empty `CLAUDE.md` was discovered.
107
+
108
+ ## Config resolution
109
+
110
+ `shared.resolvePropagateClaudeMdForNonClaudeRuntimes(project, engine)`:
111
+
112
+ 1. per-project boolean `project.propagateClaudeMdForNonClaudeRuntimes`, else
113
+ 2. fleet-wide boolean `engine.propagateClaudeMdForNonClaudeRuntimes`, else
114
+ 3. hardcoded default `true`.
115
+
116
+ Only an explicit boolean counts as "set" at either level (undefined/null/string
117
+ fall through). Editable from **Settings → Copilot Tuning → "Propagate CLAUDE.md
118
+ to non-Claude runtimes"** or via `POST /api/settings`.
@@ -70,6 +70,16 @@ If an agent is killed as an orphan and the work item retries, cooldowns use expo
70
70
 
71
71
  The engine.js *process itself* (not an agent) can crash and get auto-respawned by three independent mechanisms: `engine/supervisor.js#checkEngine` (dead-PID), `engine/supervisor.js#checkEngineHung` (stale-heartbeat), and `engine/watchdog.js` (OS-scheduled external recovery); `dashboard.js`'s in-process 30s watchdog also auto-restarts on a dead PID (the user-triggered Restart Engine button is excluded). Each successful respawn calls `shared.recordEngineRespawn(source)`, which appends `{ts, source}` to `control.json`'s `engineRespawns[]` (rolling window, default `CRASH_LOOP_WINDOW_MS` = 10 min). Once respawns within the window reach `CRASH_LOOP_THRESHOLD` (default 3), `control.crashLoopAlert` is set (dashboard-visible via `/api/status`) and a deduped (one-per-day) `notes/inbox/` alert is written; the alert clears automatically once the window rolls past the incident. Override the window/threshold via `MINIONS_CRASH_LOOP_WINDOW_MS` / `MINIONS_CRASH_LOOP_THRESHOLD` env vars.
72
72
 
73
+ ### 7. Exclusive Restart Topology
74
+
75
+ Managed dashboard spawns must bind the requested port; automatic fallback remains available only for standalone `node dashboard.js` use. Before a supervisor respawn, every matching stale daemon is killed and confirmed dead. If any PID survives, the supervisor skips the replacement and retries on its next tick instead of creating a second engine or a dashboard on another port.
76
+
77
+ `minions restart` verifies ownership of the exact engine, dashboard, and supervisor PIDs it spawned and rejects duplicate daemon processes. A failed verification tears down the partial stack and leaves `stop-intent.json` set so watchdogs cannot race another replacement into the failed topology.
78
+
79
+ PID-bearing file locks are reaped immediately when their recorded owner is dead. The five-minute stale threshold remains for legacy locks without owner metadata and the longer safety window remains for live holders.
80
+
81
+ Dashboard hot polling avoids recurring event-loop stalls: freshness-directory walks are coalesced briefly, plan scans are cached for 30 seconds and processed in cooperative batches, and quarantine-ref Git reads run asynchronously in parallel.
82
+
73
83
  ## Safe Restart Pattern
74
84
 
75
85
  ```bash