@yemi33/minions 0.1.2321 → 0.1.2323

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.
@@ -220,6 +220,25 @@ function _collectAutoCleanablePaths(dirtyFiles) {
220
220
  return cleanable.length > 0 ? cleanable : null;
221
221
  }
222
222
 
223
+ // W-mr3lokxs — Cap a porcelain dirty-file list before it is embedded verbatim
224
+ // into a live-checkout-dirty inbox alert body. On GVFS/virtual repos (see the
225
+ // "~120k virtual dirty files" note in prepareLiveCheckout below) a single
226
+ // refusal can report tens of thousands of "modified" paths; embedding the full
227
+ // list produced multi-MB / tens-of-MB alert bodies that then bloated the
228
+ // knowledge base (240 dirty-worktree dumps ≈ 113 MB). Keep the first `maxLines`
229
+ // entries and replace the remainder with a single truncation-notice line so the
230
+ // alert stays human-scannable and bounded. Pure/deterministic — exported for
231
+ // unit testing.
232
+ const DIRTY_ALERT_MAX_LINES = 200;
233
+ function capDirtyFileLines(dirtyFiles, maxLines = DIRTY_ALERT_MAX_LINES) {
234
+ const lines = Array.isArray(dirtyFiles) ? dirtyFiles : [];
235
+ const cap = Number.isInteger(maxLines) && maxLines > 0 ? maxLines : DIRTY_ALERT_MAX_LINES;
236
+ if (lines.length <= cap) return lines.slice();
237
+ const kept = lines.slice(0, cap);
238
+ kept.push(`... and ${lines.length - cap} more (run \`git status --porcelain=v1 -b\` locally for the full list)`);
239
+ return kept;
240
+ }
241
+
223
242
  // issue #608 — detects a branch name that the ENGINE created for a live-mode
224
243
  // dispatch (never the operator's own branch). Matches ONLY the literal
225
244
  // `work/<wi-id>` convention produced by `shared.deriveWorkItemBranchName` —
@@ -362,6 +381,23 @@ async function prepareLiveCheckout(opts = {}) {
362
381
  // gitOpts.maxBuffer still wins (spread after the default).
363
382
  const baseOpts = { cwd: localPath, maxBuffer: LIVE_CHECKOUT_GIT_MAX_BUFFER, ...(gitOpts || {}) };
364
383
 
384
+ // ── Step 0 (W-mr3tayu4): clear a stale .git/index.lock BEFORE any preflight
385
+ // git command runs. A leftover index.lock (from a crashed/killed git
386
+ // process) makes every subsequent git invocation fail with "Unable to
387
+ // create '<path>/.git/index.lock': File exists." In live-checkout mode the
388
+ // engine never git-reset/cleans the operator tree, so nothing else clears
389
+ // it — and the LIVE_CHECKOUT_FAILED classification is retryable, so every
390
+ // automatic retry hit the SAME stale lock and failed identically forever
391
+ // until an operator manually deleted it (observed incident: C:/office/src).
392
+ // Mirror the worktree-mode self-heal (engine.js `git worktree add` retry
393
+ // paths) by calling the SHARED age-gated (>300000ms) remover — see
394
+ // engine.js#removeStaleIndexLock, which now also delegates here. Because
395
+ // each retry re-enters spawnAgent → prepareLiveCheckout, running this at the
396
+ // top also clears a lock that appears mid-retry, not just at first preflight.
397
+ // The 5-min age gate is intentionally conservative so a lock held by a
398
+ // currently-running git process is never removed out from under it.
399
+ shared.removeStaleIndexLock(localPath, { log: (level, msg) => logFn(msg, level) });
400
+
365
401
  // ── Step 1: git status --porcelain=v1 -b. Bail early on dirty tree. ─────
366
402
  // Skipped when `skipDirtyCheck:true` (issue #522): GVFS/VFS-for-Git repos
367
403
  // report all un-hydrated virtual files as modified — this is normal GVFS
@@ -1234,5 +1270,7 @@ module.exports = {
1234
1270
  _looksLikeAgentBranch,
1235
1271
  _extractBranchFromPorcelainHeader,
1236
1272
  _commitAgentWipWithRetry,
1273
+ capDirtyFileLines,
1274
+ DIRTY_ALERT_MAX_LINES,
1237
1275
  LIVE_CHECKOUT_AUTO_CLEAN_PATTERNS,
1238
1276
  };
@@ -386,6 +386,15 @@ const PLAYBOOK_OPTIONAL_VARS = new Set([
386
386
  // produce matches. Optional for the same reason as the review-only alias.
387
387
  'project_skills_block',
388
388
  'skip_project_skills',
389
+ // P-f52d81ba — monorepo-aware harness scoping. The engine derives a dominant
390
+ // sub-project for the dispatch (explicit meta.workdir first segment, or
391
+ // detectDominantSubproject over the current dispatch's changed paths) and threads
392
+ // it here so playbook.js can pass it as `scopeSubproject` into project-skill
393
+ // discovery. Optional with an empty-string default: single-root projects and
394
+ // mixed/root diffs legitimately resolve it to '' (flat discovery), and no
395
+ // playbook references {{dominant_subproject}} directly, so the unresolved-var check
396
+ // must stay quiet when it is unset.
397
+ 'dominant_subproject',
389
398
  // M006 — typed handoff envelope fields. Set on a follow-up dispatch meta
390
399
  // when lifecycle.js queues the item after a completing agent so the
391
400
  // receiving agent has explicit context about who handed off and why.
@@ -401,6 +410,7 @@ const PLAYBOOK_OPTIONAL_VARS = new Set([
401
410
  'prior_explore_context',
402
411
  ]);
403
412
 
413
+
404
414
  const PLAYBOOK_REQUIRED_VARS = {
405
415
  'implement': ['item_id', 'item_name', 'branch_name', 'project_path'],
406
416
  'implement-shared': ['item_id', 'item_name', 'branch_name', 'worktree_path'],
@@ -814,7 +824,27 @@ function renderPlaybook(type, vars) {
814
824
  const effectiveType = vars.parent_dispatch_type || type;
815
825
  const intents = intentsForPlaybook(effectiveType);
816
826
  if (intents.length > 0) {
817
- const entries = discover.discoverProjectSkills({ projectPath });
827
+ // P-f52d81ba monorepo-aware scoping. When the engine derived a
828
+ // dominant sub-project for this dispatch (git diff / PR files /
829
+ // references → detectDominantSubproject, threaded in via vars.dominant_subproject),
830
+ // surface that sub-project's skills FIRST. Empty/unset ⇒ today's flat
831
+ // global name-sort. Use the diagnostics variant so per-surface
832
+ // overflow (entries dropped past the maxFilesPerSurface cap) is
833
+ // observable instead of silent.
834
+ const scopeSubproject = typeof vars.dominant_subproject === 'string' ? vars.dominant_subproject : '';
835
+ const { entries, truncations } = discover.discoverProjectSkillsWithDiagnostics({
836
+ projectPath,
837
+ scopeSubproject,
838
+ opts: { maxFilesPerSurface: ENGINE_DEFAULTS.projectSkillMaxFilesPerSurface },
839
+ });
840
+ if (Array.isArray(truncations) && truncations.length > 0) {
841
+ log('warn', `discover-project-skills: ${truncations.length} surface(s) exceeded maxFilesPerSurface (entries dropped) for project ${matchedProject?.name || projectPath}${scopeSubproject ? ', subproject=' + scopeSubproject : ''}: ${truncations.map(t => `${t.dir} (${t.scanned}/${t.total})`).join('; ')}`);
842
+ }
843
+ // Stash discovery diagnostics on the vars object so the engine
844
+ // caller (spawnAgent) can record detected sub-project + truncations onto
845
+ // the dispatch record for later grounding/observability. Best-effort:
846
+ // callers that don't read it simply ignore the field.
847
+ vars._discoveryDiagnostics = { subproject: scopeSubproject, truncations: Array.isArray(truncations) ? truncations : [] };
818
848
  const filtered = discover.filterByIntents(entries, intents);
819
849
  // type === 'review' keeps PR-82 header copy + meta.review.* outcome
820
850
  // guidance; everything else uses the generic header that points at
package/engine/queries.js CHANGED
@@ -949,6 +949,15 @@ function _pickPrDedupeWinner(group, projects) {
949
949
  return { winner, dropped };
950
950
  }
951
951
 
952
+ // W-mr3mvgfe — terminal PR statuses (done being polled/reviewed) get pushed
953
+ // to the bottom of the PR tab's default sort. Includes `CLOSED` (a PR closed
954
+ // without merging, distinct from `ABANDONED` but equally inactive — see
955
+ // engine/github.js `newStatus = PR_STATUS.CLOSED` on `prData.state === 'closed'`
956
+ // without `merged`) alongside `MERGED`/`ABANDONED`.
957
+ function _isTerminalPrStatus(status) {
958
+ return status === PR_STATUS.MERGED || status === PR_STATUS.ABANDONED || status === PR_STATUS.CLOSED;
959
+ }
960
+
952
961
  function getPullRequests(config) {
953
962
  const now = Date.now();
954
963
  if (_prsCache && (now - _prsCacheAt) < 1000) return _prsCache;
@@ -1018,6 +1027,12 @@ function getPullRequests(config) {
1018
1027
  allPrs.push(pr);
1019
1028
  }
1020
1029
  allPrs.sort((a, b) => {
1030
+ // W-mr3mvgfe: group terminal PRs (merged/abandoned/closed) after active
1031
+ // ones so a stale, already-resolved PR doesn't crowd out active work at
1032
+ // the top of the list regardless of its `created` timestamp.
1033
+ const aTerminal = _isTerminalPrStatus(a.status) ? 1 : 0;
1034
+ const bTerminal = _isTerminalPrStatus(b.status) ? 1 : 0;
1035
+ if (aTerminal !== bTerminal) return aTerminal - bTerminal;
1021
1036
  // W-mpej044m00076d63: sort by the full ISO `created` timestamp DESC so
1022
1037
  // same-day PRs preserve creation order (previously the slice-to-date
1023
1038
  // collapsed every PR opened on the same day into one bucket, then tied
package/engine/shared.js CHANGED
@@ -2583,6 +2583,37 @@ function shellSafeGh(args, opts = {}) {
2583
2583
  }).then(({ stdout }) => stdout);
2584
2584
  }
2585
2585
 
2586
+ // W-mr3tayu4 — shared, age-gated stale `.git/index.lock` remover. Previously
2587
+ // lived only in engine.js and was called from the worktree-mode `git worktree
2588
+ // add` retry paths; it is now shared so engine/live-checkout.js#prepareLiveCheckout
2589
+ // (checkoutMode:'live' projects) can self-heal the same failure without a
2590
+ // circular import. Removes `<rootDir>/.git/index.lock` ONLY when it is older
2591
+ // than STALE_INDEX_LOCK_MAX_AGE_MS (5 min) — deliberately conservative so a lock
2592
+ // held by a currently-running git process is never deleted out from under it.
2593
+ // All fs/log seams are injectable for unit tests; production callers pass none.
2594
+ const STALE_INDEX_LOCK_MAX_AGE_MS = 300000;
2595
+
2596
+ function removeStaleIndexLock(rootDir, opts = {}) {
2597
+ const {
2598
+ log: logOverride, // (level, msg) => void — defaults to the shared `log`
2599
+ _exists = fs.existsSync,
2600
+ _stat = fs.statSync,
2601
+ _unlink = fs.unlinkSync,
2602
+ _now = Date.now,
2603
+ } = opts;
2604
+ const emit = typeof logOverride === 'function' ? logOverride : (level, msg) => log(level, msg);
2605
+ const lockFile = path.join(rootDir, '.git', 'index.lock');
2606
+ try {
2607
+ if (_exists(lockFile)) {
2608
+ const age = _now() - _stat(lockFile).mtimeMs;
2609
+ if (age > STALE_INDEX_LOCK_MAX_AGE_MS) {
2610
+ _unlink(lockFile);
2611
+ emit('warn', `Removed stale index.lock (${Math.round(age / 1000)}s old) in ${rootDir}`);
2612
+ }
2613
+ }
2614
+ } catch (e) { emit('warn', 'git: ' + e.message); }
2615
+ }
2616
+
2586
2617
  function shellSafeGit(args, opts = {}) {
2587
2618
  if (!Array.isArray(args)) {
2588
2619
  return Promise.reject(new TypeError('shellSafeGit: args must be an array'));
@@ -2796,11 +2827,44 @@ const KB_READABLE_CATEGORIES = Object.freeze([
2796
2827
  'consolidated', 'consolidation', 'consolidations',
2797
2828
  'team-memory', 'general', 'patterns',
2798
2829
  ]);
2830
+ /**
2831
+ * Detect engine-authored system alerts (dirty-tree refusals, worktree-skip-live
2832
+ * guard notices, blocked/conflicted live-checkout dispatches, managed-spawn
2833
+ * failures, ADO-auth alerts, …). These are operational noise, NOT durable
2834
+ * reusable knowledge, and the same underlying incident recurs verbatim across
2835
+ * many different inbox filenames (one per worktree/date), so consolidation
2836
+ * must never copy their full body into knowledge/ as a distinct "new" entry
2837
+ * per recurrence — see W-mr3pi9de (worktree-skip-live note titles were
2838
+ * identical across every firing regardless of worktree, producing 30+
2839
+ * duplicate-titled knowledge/project-notes/*.md entries on a single day) and
2840
+ * the sibling W-mr3lokxs fix for live-checkout-dirty alerts.
2841
+ *
2842
+ * `engine/dispatch.js#writeInboxAlert` names every file `engine-alert-<slug>-
2843
+ * <date>.md`, so the filename prefix is one signal. `shared.js#_writeWorktree
2844
+ * SkipLiveInboxNote` (and any other engine-authored note) stamps frontmatter
2845
+ * `agent: engine`, accepted as a second signal so alerts written under a
2846
+ * different filename convention are still recognized.
2847
+ */
2848
+ function isEngineSystemAlert(name, content) {
2849
+ const nameLower = (name || '').toLowerCase();
2850
+ if (nameLower.includes('engine-alert-') || nameLower.includes('engine-worktree-skip-live-')) return true;
2851
+ const fmMatch = String(content || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
2852
+ if (fmMatch && /^agent:\s*engine\s*$/im.test(fmMatch[1])) return true;
2853
+ return false;
2854
+ }
2855
+
2799
2856
  /**
2800
2857
  * Classify an inbox item into a knowledge base category.
2801
2858
  * Single source of truth — used by consolidation.js (both LLM and regex paths).
2802
2859
  */
2803
2860
  function classifyInboxItem(name, content) {
2861
+ // W-mr3pi9de / W-mr3lokxs — engine-authored system alerts are operational
2862
+ // noise. Route them to 'project-notes' BEFORE the content-substring
2863
+ // heuristics below, so a note whose body incidentally contains substrings
2864
+ // like 'lint', 'review', or 'pr-' can't be misclassified into
2865
+ // build-reports/reviews. classifyToKnowledgeBase additionally forces these
2866
+ // non-reusable and content-hash-dedups them regardless of category.
2867
+ if (isEngineSystemAlert(name, content)) return 'project-notes';
2804
2868
  const nameLower = (name || '').toLowerCase();
2805
2869
  const contentLower = (content || '').toLowerCase();
2806
2870
  if (nameLower.includes('review') || nameLower.includes('pr-') || nameLower.includes('pr4') || nameLower.includes('feedback')) return 'reviews';
@@ -3372,6 +3436,15 @@ const ENGINE_DEFAULTS = {
3372
3436
  // closed by default. Set to false to fall back to the legacy
3373
3437
  // "only committed assets are visible" behavior.
3374
3438
  harnessPropagateProjectLocal: true,
3439
+ // P-7c1a9e42 — cap on how many skill packs / command files
3440
+ // engine/discover-project-skills.js reads per surface (root `.claude/skills`,
3441
+ // each `<area>/.claude/skills`, etc.). Raised well above the module-level
3442
+ // DEFAULTS.maxFilesPerSurface fallback (50) so large monorepo sub-projects (e.g.
3443
+ // office/src/ocm/.claude/skills with ~80 packages) don't silently drop the
3444
+ // most-relevant skills past the cap. engine/playbook.js threads this into the
3445
+ // discovery `opts`; when no override is supplied the module falls back to its
3446
+ // own DEFAULTS.maxFilesPerSurface. See docs/project-skills.md.
3447
+ projectSkillMaxFilesPerSurface: 200,
3375
3448
  // P-49e1c8b7 — hermetic harness opt-out. When TRUE the spawned agent runs
3376
3449
  // with a known-empty harness surface around the worktree:
3377
3450
  // - `--add-dir` is exactly `[minionsDir]` (user-scope skill/command/MCP
@@ -8853,7 +8926,13 @@ function _writeWorktreeSkipLiveInboxNote(worktreePath, callerTag, blockingInfo)
8853
8926
  `date: ${date}`,
8854
8927
  '---',
8855
8928
  '',
8856
- `# Engine skipped worktree wipe live dispatch guard fired (W-mq5rwwss000f30a7)`,
8929
+ // W-mr3pi9de embed the actual worktree basename, not a hardcoded
8930
+ // literal. The previous fixed string made every note (and therefore
8931
+ // every knowledge/project-notes/*.md entry consolidation wrote from
8932
+ // it) share byte-identical titles regardless of which worktree fired
8933
+ // the guard, defeating uniquePath's -2/-3/… suffixing and flooding the
8934
+ // KB with near-duplicate-content entries under different numbers.
8935
+ `# Engine skipped worktree wipe — live dispatch guard fired (${base})`,
8857
8936
  '',
8858
8937
  `- caller: ${callerTag || 'unknown'}`,
8859
8938
  `- worktree: ${worktreePath}`,
@@ -9374,6 +9453,8 @@ module.exports = {
9374
9453
  execSilent,
9375
9454
  shellSafeGh,
9376
9455
  shellSafeGit,
9456
+ removeStaleIndexLock,
9457
+ STALE_INDEX_LOCK_MAX_AGE_MS,
9377
9458
  shellSafeGitSync,
9378
9459
  validateGitRef,
9379
9460
  validateGhSlug,
@@ -9392,6 +9473,7 @@ module.exports = {
9392
9473
  KB_CATEGORIES,
9393
9474
  KB_READABLE_CATEGORIES,
9394
9475
  classifyInboxItem,
9476
+ isEngineSystemAlert,
9395
9477
  ENGINE_DEFAULTS,
9396
9478
  resolvePollFlag, // P-c4d8e1a3 — granular per-poller flag resolution
9397
9479
  resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentModel, resolveCcModel,
package/engine.js CHANGED
@@ -933,17 +933,13 @@ async function _fetchWithTransientRetry(args, opts, label) {
933
933
  }
934
934
  }
935
935
 
936
+ // W-mr3tayu4 — thin wrapper around shared.removeStaleIndexLock. The age-gated
937
+ // (>300000ms) stale-lock removal now lives in engine/shared.js so both the
938
+ // worktree-add retry paths here and engine/live-checkout.js#prepareLiveCheckout
939
+ // share one implementation (no circular import). Kept as a named local because
940
+ // three internal call sites and the test harness reference `removeStaleIndexLock`.
936
941
  function removeStaleIndexLock(rootDir) {
937
- const lockFile = path.join(rootDir, '.git', 'index.lock');
938
- try {
939
- if (fs.existsSync(lockFile)) {
940
- const age = Date.now() - fs.statSync(lockFile).mtimeMs;
941
- if (age > 300000) {
942
- fs.unlinkSync(lockFile);
943
- log('warn', `Removed stale index.lock (${Math.round(age / 1000)}s old) in ${rootDir}`);
944
- }
945
- }
946
- } catch (e) { log('warn', 'git: ' + e.message); }
942
+ return shared.removeStaleIndexLock(rootDir);
947
943
  }
948
944
 
949
945
  async function runWorktreeAdd(rootDir, worktreePath, addArgs, gitOpts, worktreeCreateRetries) {
@@ -1927,6 +1923,38 @@ async function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts
1927
1923
  }
1928
1924
  }
1929
1925
 
1926
+ // P-f52d81ba — persist the dispatch's detected monorepo sub-project (+ any per-surface
1927
+ // skill-discovery truncations the render surfaced) onto the dispatch record so
1928
+ // later grounding/observability can see which sub-project scoped discovery + harness
1929
+ // propagation. Best-effort: mutation failures must never block the spawn. Only
1930
+ // writes fields that carry a value so unset sub-projects leave the record untouched.
1931
+ function _recordDispatchDiscoveryDiagnostics(id, dispatchItem, subproject, diagnostics) {
1932
+ try {
1933
+ const dominantSubproject = (typeof subproject === 'string' && subproject) ? subproject : undefined;
1934
+ const truncations = diagnostics && Array.isArray(diagnostics.truncations) && diagnostics.truncations.length
1935
+ ? diagnostics.truncations
1936
+ : undefined;
1937
+ if (dominantSubproject === undefined && truncations === undefined) return;
1938
+ mutateDispatch((dispatch) => {
1939
+ for (const queue of ['pending', 'active', 'completed']) {
1940
+ const arr = Array.isArray(dispatch?.[queue]) ? dispatch[queue] : null;
1941
+ if (!arr) continue;
1942
+ const found = arr.find(d => d && d.id === id);
1943
+ if (!found) continue;
1944
+ if (dominantSubproject !== undefined) found._dominantSubproject = dominantSubproject;
1945
+ if (truncations !== undefined) found._skillDiscoveryTruncations = truncations;
1946
+ }
1947
+ return dispatch;
1948
+ });
1949
+ if (dispatchItem) {
1950
+ if (dominantSubproject !== undefined) dispatchItem._dominantSubproject = dominantSubproject;
1951
+ if (truncations !== undefined) dispatchItem._skillDiscoveryTruncations = truncations;
1952
+ }
1953
+ } catch (e) {
1954
+ log('warn', `record discovery diagnostics failed for ${id} (non-fatal): ${e.message}`);
1955
+ }
1956
+ }
1957
+
1930
1958
  // Seed a spawned agent's COPILOT_HOME mcp-config as a copy of the operator's
1931
1959
  // `~/.copilot/mcp-config.json` MINUS the servers in
1932
1960
  // `engine.copilotAgentDisabledMcpServers`, then point COPILOT_HOME at it.
@@ -2555,7 +2583,7 @@ async function spawnAgent(dispatchItem, config) {
2555
2583
  '## Dirty files (`git status --porcelain=v1 -b`)',
2556
2584
  '',
2557
2585
  '```',
2558
- ...(_dirtyFiles.length > 0 ? _dirtyFiles : ['(none reported)']),
2586
+ ...(_dirtyFiles.length > 0 ? _liveCheckout.capDirtyFileLines(_dirtyFiles) : ['(none reported)']),
2559
2587
  '```',
2560
2588
  '',
2561
2589
  _autoCleanupEnabled
@@ -3894,6 +3922,45 @@ async function spawnAgent(dispatchItem, config) {
3894
3922
 
3895
3923
  updateAgentStatus(id, AGENT_STATUS.READY, 'Worktree ready, preparing to spawn process');
3896
3924
 
3925
+ // ── P-f52d81ba — derive the effective monorepo sub-project for this dispatch ──────
3926
+ // Feeds BOTH project-skill discovery (scopeSubproject → sub-project's skills lead the
3927
+ // rendered project_skills_block) and project-local harness propagation
3928
+ // (workdir clip → only the sub-project's --project-harness-dir roots are surfaced).
3929
+ // Precedence:
3930
+ // 1. Explicit meta.workdir wins — effective sub-project = its first segment. No
3931
+ // diff work (the operator already declared the target sub-package).
3932
+ // 2. Else best-effort from the CURRENT dispatch's diff: `git diff
3933
+ // --name-only <main>...HEAD` in this worktree → detectDominantSubproject.
3934
+ // 3. Else '' (flat discovery / unclipped harness — today's behavior).
3935
+ // Contract: never blocks dispatch, never changes agent cwd (only an explicit
3936
+ // meta.workdir moves cwd, applied separately below), and degrades to '' on
3937
+ // any failure. Derived from THIS worktree's HEAD so a follow-up/parent's
3938
+ // stale diff can't leak in (AC: current-dispatch diff only).
3939
+ let _effectiveSubproject = '';
3940
+ try {
3941
+ if (validatedWorkdir) {
3942
+ _effectiveSubproject = String(validatedWorkdir).replace(/\\/g, '/').replace(/^\/+/, '').split('/')[0] || '';
3943
+ } else if (worktreePath && fs.existsSync(worktreePath) && project?.localPath) {
3944
+ let _subprojectChangedPaths = [];
3945
+ try {
3946
+ const _subprojectBase = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
3947
+ const _subprojectDiff = await execAsync(`git diff --name-only ${_subprojectBase}...HEAD`, { ..._gitOpts, cwd: worktreePath, timeout: 10000 });
3948
+ _subprojectChangedPaths = (_subprojectDiff.stdout || '').split('\n').map(s => s.trim()).filter(Boolean);
3949
+ } catch (e) {
3950
+ log('debug', `subproject-detect: git diff failed for ${id} (flat discovery): ${e.message}`);
3951
+ }
3952
+ if (_subprojectChangedPaths.length) {
3953
+ const { detectDominantSubproject } = require('./engine/discover-project-skills')._internal;
3954
+ const _det = detectDominantSubproject({ projectPath: project.localPath, changedPaths: _subprojectChangedPaths });
3955
+ if (_det && _det.confident && _det.subproject) _effectiveSubproject = _det.subproject;
3956
+ }
3957
+ }
3958
+ } catch (e) {
3959
+ log('warn', `subproject-detect failed for ${id} (non-fatal, flat discovery): ${e.message}`);
3960
+ _effectiveSubproject = '';
3961
+ }
3962
+
3963
+ let _refreshedDiscoveryDiagnostics = null;
3897
3964
  if (worktreePath && meta?.source === 'work-item' && meta?.item?.branchStrategy === 'shared-branch') {
3898
3965
  const refreshed = renderProjectWorkItemPromptForAgent(
3899
3966
  meta.item,
@@ -3903,15 +3970,21 @@ async function spawnAgent(dispatchItem, config) {
3903
3970
  project,
3904
3971
  rootDir,
3905
3972
  branchName,
3906
- { worktreePath }
3973
+ { worktreePath, dominantSubproject: _effectiveSubproject }
3907
3974
  );
3908
3975
  if (refreshed.prompt) {
3909
3976
  taskPrompt = refreshed.prompt;
3910
3977
  fullTaskPrompt = buildFullTaskPrompt(taskPrompt);
3911
3978
  safeWrite(promptPath, fullTaskPrompt);
3912
- log('info', `Refreshed shared-branch prompt for ${id} with worktree ${worktreePath}`);
3979
+ log('info', `Refreshed shared-branch prompt for ${id} with worktree ${worktreePath}${_effectiveSubproject ? ` (subproject=${_effectiveSubproject})` : ''}`);
3913
3980
  }
3981
+ _refreshedDiscoveryDiagnostics = refreshed.discoveryDiagnostics || null;
3914
3982
  }
3983
+ // Record the detected sub-project (+ any per-surface skill-discovery truncations the
3984
+ // shared-branch re-render surfaced) onto the dispatch record for later
3985
+ // grounding/observability. Sub-project-only for non-re-render dispatches (their
3986
+ // prompt was rendered at dispatch-build time; truncations are logged there).
3987
+ _recordDispatchDiscoveryDiagnostics(id, dispatchItem, _effectiveSubproject, _refreshedDiscoveryDiagnostics);
3915
3988
 
3916
3989
  // Inject dirty file list when worktree has uncommitted changes (e.g., max_turns retry)
3917
3990
  // This signals to the respawned agent that prior work exists in the worktree (#960)
@@ -4223,7 +4296,12 @@ async function spawnAgent(dispatchItem, config) {
4223
4296
  const filteredDirs = shared.filterProjectHarnessDirsForWorkdir(candidateDirs, {
4224
4297
  projectLocalPath: project.localPath,
4225
4298
  worktreePath,
4226
- workdir: validatedWorkdir,
4299
+ // P-f52d81ba — clip to the effective sub-project. Explicit meta.workdir wins
4300
+ // (validatedWorkdir, possibly multi-segment); otherwise use the
4301
+ // auto-derived dominant sub-project (single first-level segment) so an
4302
+ // ocm/-dominant diff surfaces only ocm/'s --project-harness-dir roots.
4303
+ // Empty ⇒ no clip (today's whole-project harness surface).
4304
+ workdir: validatedWorkdir || _effectiveSubproject || null,
4227
4305
  });
4228
4306
  for (const abs of filteredDirs) {
4229
4307
  if (!fs.existsSync(abs)) continue;
@@ -4231,7 +4309,7 @@ async function spawnAgent(dispatchItem, config) {
4231
4309
  propagatedProjectHarnessDirs.push(abs);
4232
4310
  }
4233
4311
  if (projectHarnessArgs.length) {
4234
- log('debug', `Project-local harness propagation: ${projectHarnessArgs.length / 2} dir(s) for ${id} (${worktreePath}${validatedWorkdir ? ', workdir=' + validatedWorkdir : ''})`);
4312
+ log('debug', `Project-local harness propagation: ${projectHarnessArgs.length / 2} dir(s) for ${id} (${worktreePath}${validatedWorkdir ? ', workdir=' + validatedWorkdir : (_effectiveSubproject ? ', subproject=' + _effectiveSubproject : '')})`);
4235
4313
  }
4236
4314
  } catch (err) {
4237
4315
  log('warn', `Project-local harness propagation failed for ${id} (non-fatal): ${err.message}`);
@@ -7699,6 +7777,30 @@ function _buildRunnerBriefVars(item, project) {
7699
7777
  }
7700
7778
  }
7701
7779
 
7780
+ // P-f52d81ba — sync effective-sub-project derivation for the build-time prompt render
7781
+ // (renderProjectWorkItemPromptForAgent runs synchronously, so no git diff).
7782
+ // Explicit meta.workdir wins (first segment). Otherwise best-effort from the
7783
+ // work item's references[*].path via detectDominantSubproject. Returns '' (flat
7784
+ // discovery) on anything unresolved. spawnAgent's async re-render overrides
7785
+ // this with a git-diff-derived sub-project via options.dominantSubproject.
7786
+ function _deriveDominantSubprojectSync(item, project) {
7787
+ try {
7788
+ const wd = item && item.meta && item.meta.workdir;
7789
+ if (typeof wd === 'string' && wd.trim()) {
7790
+ return wd.replace(/\\/g, '/').replace(/^\/+/, '').split('/')[0] || '';
7791
+ }
7792
+ const projectPath = project && project.localPath;
7793
+ if (!projectPath) return '';
7794
+ const paths = (item && Array.isArray(item.references))
7795
+ ? item.references.map(r => (r && typeof r.path === 'string') ? r.path : '').filter(Boolean)
7796
+ : [];
7797
+ if (paths.length === 0) return '';
7798
+ const { detectDominantSubproject } = require('./engine/discover-project-skills')._internal;
7799
+ const det = detectDominantSubproject({ projectPath, changedPaths: paths });
7800
+ return (det && det.confident && det.subproject) ? det.subproject : '';
7801
+ } catch { return ''; }
7802
+ }
7803
+
7702
7804
  /**
7703
7805
  * Scan work-items.json for manually queued tasks
7704
7806
  */
@@ -7873,6 +7975,14 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
7873
7975
  // Either flag suppresses both blocks on the dispatch.
7874
7976
  skip_project_review_skills: !!(item.meta && item.meta.skipProjectReviewSkills),
7875
7977
  skip_project_skills: !!(item.meta && (item.meta.skipProjectSkills || item.meta.skipProjectReviewSkills)),
7978
+ // P-f52d81ba — effective monorepo sub-project for scoped skill discovery. When the
7979
+ // async caller (spawnAgent) supplies a git-diff-derived sub-project it wins (even
7980
+ // ''); otherwise derive best-effort from explicit meta.workdir /
7981
+ // references[*].path. Empty ⇒ flat discovery. playbook.js reads this as the
7982
+ // discovery scopeSubproject.
7983
+ dominant_subproject: (typeof options.dominantSubproject === 'string')
7984
+ ? options.dominantSubproject
7985
+ : _deriveDominantSubprojectSync(item, project),
7876
7986
  // M006 — typed handoff envelope. Set on follow-up dispatch metas by
7877
7987
  // lifecycle.js when queuing an item after a completing agent. The
7878
7988
  // renderPlaybook path injects a "Handoff Context" section when truthy
@@ -7892,10 +8002,16 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
7892
8002
  if (playbookName === 'work-item' && workType === WORK_TYPE.REVIEW) {
7893
8003
  log('info', `Work item ${item.id} is type "review" but has no PR — using work-item playbook`);
7894
8004
  }
8005
+ const prompt = item.prompt || renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars) || item.description;
7895
8006
  return {
7896
8007
  needsReview: false,
7897
8008
  checkpointCount: cpResult.checkpointCount,
7898
- prompt: item.prompt || renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars) || item.description,
8009
+ prompt,
8010
+ // P-f52d81ba — surface the discovery diagnostics renderPlaybook stashed on
8011
+ // the vars object (detected sub-project + per-surface truncations) so spawnAgent
8012
+ // can record them onto the dispatch record. null when no playbook render
8013
+ // ran (item.prompt short-circuit) or discovery was skipped.
8014
+ discoveryDiagnostics: vars._discoveryDiagnostics || null,
7899
8015
  };
7900
8016
  }
7901
8017
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2321",
3
+ "version": "0.1.2323",
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"
@@ -89,16 +89,25 @@ If you start a small task and discover it's actually Medium (3+ files, more tool
89
89
 
90
90
  When genuinely in doubt about the size, delegate — agents have isolated worktrees, full tool access, durable work-item tracking, and no turn limits.
91
91
 
92
+ ### HARD STOP — never edit files yourself in a live/hybrid project
93
+ This overrides everything above, **including the direct-handling override**. For any project whose effective checkout mode is `live` (or `hybrid` for a code-authoring work-item type — `implement`/`fix`/`docs`/`decompose`), you MUST NOT use `Edit`/`Write`/`Bash` (or any tool) to mutate files inside that project's `localPath`, **regardless of task size** — not even a Small 1-line change, and not even when the human explicitly says to do it yourself. The Step-3 "do it yourself" allowance and the Step-1 direct-handling override do **not** apply to writes inside a live/hybrid checkout.
94
+
95
+ Why: live-mode projects have no worktree isolation — the engine runs agents in-place inside `localPath`, caps dispatch to one mutating operation at a time, and refuses to dispatch on a dirty tree (`live-checkout-dirty`, non-retryable). A stray CC edit leaves the checkout dirty in a way the dispatch bookkeeping does not track, and that dirty state then **blocks every subsequent live-mode dispatch** on the project until a human manually cleans it up. It is NOT covered by the engine's dispatch-time auto-stash/auto-reset recovery — those only run as part of an actual dispatch preflight, never for stray CC edits.
96
+
97
+ Instead, when a change is needed in a live/hybrid project, **dispatch a normal work item** (`POST /api/work-items`, `type: "implement"` or `"fix"` as usual) and let the engine own the live-checkout dispatch path (dirty-tree refusal, single-mutating-dispatch cap, auto-stash/auto-reset). Do not try to shortcut it with your own edit.
98
+
99
+ Read-only inspection is still fine to do yourself in a live/hybrid checkout: viewing files, `git status`/`git log`/`git diff`, and non-mutating build/test commands are all fair game. The restriction is specifically about **writes**. See "Project checkout modes (worktree / live / hybrid)" below for the mode contract this rule protects.
100
+
92
101
  ### After you make a local code edit yourself — offer to open a PR
93
- When you edit files **yourself** in a configured project's working tree (the Step-3 "do it yourself" path NOT a change a dispatched agent made in its own worktree), don't leave the diff sitting uncommitted. After you finish editing, call:
102
+ This flow is **worktree-mode only** it applies solely to projects whose effective checkout mode is `worktree`. For `live`/`hybrid` projects you never edit files yourself (see the HARD STOP above), so there is nothing to PR from a CC edit. If you ever find uncommitted changes sitting in a live-mode project's checkout anyway, do **not** commit or push them yourself — just report the dirty state to the user and let them decide how to clean it up.
103
+
104
+ When you edit files **yourself** in a configured worktree-mode project's working tree (the Step-3 "do it yourself" path — NOT a change a dispatched agent made in its own worktree), don't leave the diff sitting uncommitted. After you finish editing, call:
94
105
  ```
95
106
  curl -s -X POST http://localhost:{{dashboard_port}}/api/pr-action/offer-create-pr \
96
107
  -H 'Content-Type: application/json' -H 'X-CC-Turn-Id: {{cc_turn_id}}' \
97
108
  -d '{"project":"<project name>"}'
98
109
  ```
99
- This checks the project's working tree (`git status --porcelain`) and, when there are uncommitted changes, returns a **`[Create PR]`** follow-up chip to the user (same chip mechanism as the `pr-action` `[Comment]`/`[Fix once]`/`[Track for auto-fix]` chips). When the user clicks it, you'll receive a turn with **explicit step-by-step instructions — follow them exactly**. The instructions adapt to the project's checkout pattern, so just do what the turn says:
100
- - **Worktree-mode projects** (the default): the turn tells you to first `POST /api/pr-action/prepare-create-pr-worktree {"project":"…"}`. The server moves your uncommitted changes into an **isolated worktree** and restores the live checkout clean, returning `{worktreePath, branch, baseBranch}`. You then run **all git from inside `worktreePath`** (`git -C <worktreePath> …`) to commit/push/open the PR, then `POST /api/pr-action/cleanup-create-pr-worktree` to remove it. **Never commit, branch, or push in the live checkout** — that leaks commits onto the operator's `main`.
101
- - **Live-mode projects**: the turn tells you to commit on a new branch off main in the live checkout and switch back to the original branch when done.
110
+ This checks the project's working tree (`git status --porcelain`) and, when there are uncommitted changes, returns a **`[Create PR]`** follow-up chip to the user (same chip mechanism as the `pr-action` `[Comment]`/`[Fix once]`/`[Track for auto-fix]` chips). When the user clicks it, you'll receive a turn with **explicit step-by-step instructions — follow them exactly**. Because you only ever edit files yourself in worktree-mode projects (see the HARD STOP above), this is always the worktree-mode flow: the turn tells you to first `POST /api/pr-action/prepare-create-pr-worktree {"project":"…"}`. The server moves your uncommitted changes into an **isolated worktree** and restores the live checkout clean, returning `{worktreePath, branch, baseBranch}`. You then run **all git from inside `worktreePath`** (`git -C <worktreePath> …`) to commit/push/open the PR, then `POST /api/pr-action/cleanup-create-pr-worktree` to remove it. **Never commit, branch, or push in the live checkout** — that leaks commits onto the operator's `main`.
102
111
 
103
112
  Pass `"contextOnly":true` if the PR should be tracked-but-not-auto-reviewed; omit it to have the engine auto-manage the PR (review → fix → re-review → auto-merge). If `hasChanges` is `false`, there's nothing to PR — skip the offer. Don't commit/push on your own initiative; surface the chip and let the user decide.
104
113
 
@@ -327,6 +336,8 @@ Every configured project has an effective **checkout mode** — surfaced in your
327
336
  - **`live`** — agents run **in-place** inside the project's `localPath` (no worktree). The engine caps this to **one mutating dispatch at a time** per project and **refuses on a dirty tree**. Use only when worktrees are unworkable (e.g. Android `repo`, submodules, deep Windows paths, emulators that bind the real checkout).
328
337
  - **`hybrid`** — `live` **plus** a `liveValidation: { type, autoDispatch }` block. Coding work items (`implement`/`fix`/`docs`/`decompose`) author in **isolated worktrees** (full parallelism), while **only** work items whose type matches `liveValidation.type` (e.g. `build-and-test`) run **in-place on the live checkout**. This is the best of both: parallel code authoring + a real on-disk build/validation that can't run in a worktree. `type` accepts a single string (e.g. `"build-and-test"`) or an array of strings (e.g. `["implement","fix"]`) to route multiple work-item types to the live checkout at once.
329
338
 
339
+ **CC never writes into a live/hybrid checkout itself.** Because `live` (and the code-authoring surface of `hybrid`) runs in-place with no worktree isolation, a one-off CC edit inside such a project's `localPath` leaves the tree dirty and blocks every future live-mode dispatch (the engine refuses on a dirty tree, `live-checkout-dirty`, non-retryable — and it does not track or auto-recover stray CC edits). So for any needed change in a `live`/`hybrid` project you **dispatch a work item** and let the engine own the live-checkout path; you never `Edit`/`Write`/`Bash`-mutate files there yourself, regardless of task size or a direct-handling override. Read-only inspection remains fine. This is the same rule stated up top under "HARD STOP — never edit files yourself in a live/hybrid project"; the two sections agree by design.
340
+
330
341
  **When to recommend hybrid:** the project's build/test/validation genuinely cannot run in an isolated worktree (it needs the real checkout — submodules, a `repo`-managed tree, an emulator/dev-server bound to `localPath`, deep-path tooling), **but** you still want coding agents to work in parallel rather than serialize through the single live checkout. If the *whole* workflow must run on the real tree, use plain `live`. If nothing needs the real tree, stay on `worktree`.
331
342
 
332
343
  **Configuring it** (via the projects array on `POST /api/settings` — never hand-edit `config.json`):