@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.
package/dashboard.js CHANGED
@@ -3959,6 +3959,7 @@ function _buildSyntheticActionResultsForTurn(turnId, message, requestedAt) {
3959
3959
  if (entry.id) result.id = entry.id;
3960
3960
  if (entry.project) result.project = entry.project;
3961
3961
  if (entry.path) result.path = entry.path;
3962
+ if (entry.duplicate) result.duplicate = true;
3962
3963
  if (Array.isArray(entry.followups) && entry.followups.length) result.followups = entry.followups;
3963
3964
  results.push(result);
3964
3965
  }
@@ -6914,6 +6915,14 @@ const server = http.createServer(async (req, res) => {
6914
6915
  const createResult = createWorkItemWithDedup(wiPath, item);
6915
6916
  if (!createResult.created) {
6916
6917
  const duplicateId = createResult.duplicateOf || createResult.item?.id;
6918
+ // W-mr466ocx000l1191 — record the CC turn creation on the dedup path
6919
+ // too. The API call still succeeded (ok:true) from the caller's
6920
+ // perspective; without this, a CC turn that dispatches a work item
6921
+ // matching an existing one silently loses its confirmation chip
6922
+ // (_buildSyntheticActionResultsForTurn drains an empty list for the
6923
+ // turn, so donePayload.actions/actionResults come back empty even
6924
+ // though the LLM's tool call succeeded).
6925
+ recordCcTurnIfPresent(req, { kind: 'work-item', id: duplicateId, title: item.title, project: item.project || null, duplicate: true });
6917
6926
  return jsonReply(res, 200, { ok: true, id: duplicateId, duplicate: true, duplicateOf: duplicateId });
6918
6927
  }
6919
6928
  recordCcTurnIfPresent(req, { kind: 'work-item', id, title: item.title, project: item.project || null });
@@ -13902,6 +13911,55 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13902
13911
  return jsonReply(res, 200, { ok: true, cleared: cause, prId });
13903
13912
  }},
13904
13913
 
13914
+ // Issue #671 — sibling to clear-paused-cause above, but for the
13915
+ // headSha-keyed `_buildFixIneffective` ("infra issue suspected") pause
13916
+ // (see engine/lifecycle.js#recordBuildFixIneffective), which is tracked
13917
+ // separately from `_noOpFixes` and so isn't reachable via the endpoint
13918
+ // above. Once a fresh CI build is queued against a rebased/retried head,
13919
+ // this lets an operator clear the stale pause without hand-editing
13920
+ // pull-requests.json.
13921
+ { method: 'POST', path: '/api/pull-requests/clear-build-fix-ineffective', desc: 'Resume a paused build-fix-ineffective ("infra issue suspected") pause (issue #671) — clears _buildFixIneffective so BUILD_FAILURE auto-fix dispatch can resume', params: 'prId (canonical host:slug#number), headSha? (optional — if provided, must match the paused record\'s headSha or the clear is rejected as stale)', handler: async (req, res) => {
13922
+ const body = await readBody(req);
13923
+ const prId = typeof body?.prId === 'string' ? body.prId.trim() : '';
13924
+ const headSha = typeof body?.headSha === 'string' ? body.headSha.trim() : '';
13925
+ if (!prId) return jsonReply(res, 400, { error: 'prId required' });
13926
+ reloadConfig();
13927
+ const lifecycle = require('./engine/lifecycle');
13928
+ const prPaths = [
13929
+ ...shared.getProjects(CONFIG).map(p => shared.projectPrPath(p)),
13930
+ shared.centralPullRequestsPath(MINIONS_DIR),
13931
+ ];
13932
+ let prFound = false;
13933
+ let recordCleared = false;
13934
+ let staleHead = false;
13935
+ for (const prPath of prPaths) {
13936
+ if (recordCleared || staleHead) break;
13937
+ shared.mutatePullRequests(prPath, (prs) => {
13938
+ if (!Array.isArray(prs)) return prs;
13939
+ const target = prs.find(p => p && p.id === prId);
13940
+ if (!target) return prs;
13941
+ prFound = true;
13942
+ const record = target._buildFixIneffective;
13943
+ if (!record || typeof record !== 'object') return prs;
13944
+ // If the caller told us which head they saw the pause on (the
13945
+ // dashboard chip stamps this at render time), refuse to clear a
13946
+ // pause that has since moved on to a different head — that would
13947
+ // silently apply a stale click's intent to a newer commit.
13948
+ if (headSha && record.headSha && record.headSha !== headSha) {
13949
+ staleHead = true;
13950
+ return prs;
13951
+ }
13952
+ recordCleared = lifecycle.clearBuildFixIneffective(target);
13953
+ return prs;
13954
+ });
13955
+ }
13956
+ if (!prFound) return jsonReply(res, 404, { error: `PR ${prId} not found` });
13957
+ if (staleHead) return jsonReply(res, 409, { error: `build-fix-ineffective pause on PR ${prId} is for a different head than provided — refresh and retry` });
13958
+ if (!recordCleared) return jsonReply(res, 400, { error: `PR ${prId} has no tracked build-fix-ineffective pause` });
13959
+ invalidateStatusCache();
13960
+ return jsonReply(res, 200, { ok: true, cleared: 'build-fix-ineffective', prId });
13961
+ }},
13962
+
13905
13963
  // ─── P-f3c9d0e7: convenience pause/resume endpoints ─────────────────────
13906
13964
  // Single-call wrappers over `POST /api/settings { engine: { <flag>: bool } }`
13907
13965
  // for the two operator kill-switches:
@@ -62,6 +62,21 @@ flagged un-grounded downstream. This capture is observability only — it does
62
62
  **not** change which dirs are propagated at spawn time, and any failure here is
63
63
  non-fatal (the spawn proceeds).
64
64
 
65
+ **Monorepo sub-project contributes `--add-dir` roots (P-f52d81ba).** The `addDirs` set
66
+ folded into the manifest includes the project-local harness dirs propagated for
67
+ the dispatch. When the engine auto-derives a dominant sub-project (see
68
+ [project-skills.md → Monorepo sub-project scoping](project-skills.md#monorepo-sub-project-scoping)),
69
+ those sub-project-scoped `--project-harness-dir` roots flow into `propagatedProjectHarnessDirs`
70
+ → `addDirs` → the `_harnessPropagated` manifest. The clip that selects them is
71
+ the **same** `shared.filterProjectHarnessDirsForWorkdir` used for an explicit
72
+ `meta.workdir` — `spawnAgent` passes `workdir: validatedWorkdir || _effectiveSubproject
73
+ || null`, so an explicit `meta.workdir` wins, else the auto-derived sub-project's
74
+ first-level segment clips the surface, else no clip (whole-project harness).
75
+ Because the manifest captures the same sub-project-clipped roots the agent was actually
76
+ handed, a self-reported skill usage against a sub-project's skill (e.g.
77
+ `ocm/.claude/skills/…`) still grounds `true` — grounding stays faithful under
78
+ sub-project scoping instead of falsely flagging the sub-project's own skills.
79
+
65
80
  The grounding cross-check (P-c6f5e8b3) runs in `engine/lifecycle.js`
66
81
  `runPostCompletionHooks` once the completion report is parsed and nonce-trusted:
67
82
  `shared.groundHarnessUsed(completionReport.harnessUsed, _harnessPropagated)`
@@ -170,7 +185,8 @@ evaluation pass) can see what tooling drove a dispatch:
170
185
  ## Related
171
186
 
172
187
  - `engine/shared.js` — `HARNESS_USED_KINDS`, `HARNESS_USED_MAX_ENTRIES`,
173
- `HARNESS_USED_MAX_FIELD_LEN`, `normalizeHarnessUsed()`, `groundHarnessUsed()`
188
+ `HARNESS_USED_MAX_FIELD_LEN`, `normalizeHarnessUsed()`, `groundHarnessUsed()`,
189
+ `filterProjectHarnessDirsForWorkdir()` (the sub-project/workdir clip)
174
190
  - `engine/queries.js` — `buildHarnessPropagatedManifest()`,
175
191
  `getUserHarnesses()`, `getProjectHarnesses()`
176
192
  - `engine/lifecycle.js` — `resolveHarnessPropagated()` + the grounding
@@ -46,6 +46,67 @@ The walk is **cheap and bounded** — preserve every guardrail in
46
46
  - ≤ 50 nested areas probed
47
47
  - Missing worktree / unreadable → `[]` (never throws)
48
48
 
49
+ ## Monorepo sub-project scoping
50
+
51
+ P-f52d81ba. In a monorepo, a dispatch usually touches one sub-project (`ocm/`,
52
+ `loop/`, `officemobile/`, …) even though discovery walks every sub-project's
53
+ `.claude/skills`. Scoping surfaces that sub-project's skills **first** without hiding
54
+ the rest, so an `ocm/`-focused agent isn't forced to scroll past `loop/` tooling
55
+ to find the relevant pack.
56
+
57
+ **Changed-path → sub-project detection.** `detectDominantSubproject({ projectPath,
58
+ changedPaths })` (engine/discover-project-skills.js) counts the changed paths
59
+ per first-level directory, keyed on the POSIX first segment. It reuses
60
+ `_listAreas` for the authoritative set of valid sub-projects, so a stray first segment
61
+ that isn't a real top-level dir never counts. It returns `{ subproject, matched,
62
+ total, confident }`. The engine feeds it changed paths from:
63
+
64
+ - **spawnAgent** (async, authoritative): `git diff --name-only <mainBranch>...HEAD`
65
+ in the current dispatch's worktree — never a parent/follow-up's stale diff.
66
+ - **renderProjectWorkItemPromptForAgent** (`_deriveDominantSubprojectSync`, build-time
67
+ fallback with no git access): the work item's `references[*].path`.
68
+
69
+ **Dominant-sub-project plurality threshold.** `detectDominantSubproject` only reports a sub-project
70
+ as `confident` when the top sub-project holds **≥ 60%** of the sub-project-matched files **and**
71
+ **≥ 2** files (`dominantSubprojectMinRatio` / `dominantSubprojectMinFiles` in `DEFAULTS`,
72
+ overridable via `opts`). Ties break alphabetically for determinism. An empty,
73
+ mixed, or no-known-sub-project diff returns `{ subproject: null, confident: false }` →
74
+ flat discovery (today's global name-sort). This is deliberately conservative:
75
+ a weakly-dominant or split diff falls back to flat surfacing rather than
76
+ mis-scoping to the wrong sub-project.
77
+
78
+ **Explicit `meta.workdir` wins over auto-detection.** When the operator declares
79
+ `meta.workdir` on the work item, the effective sub-project is its first path segment —
80
+ no diff work is done. Auto-detection only runs when `meta.workdir` is absent.
81
+ The async git-diff-derived sub-project (threaded into the shared-branch re-render via
82
+ `options.dominantSubproject`) overrides the sync `references`-derived guess.
83
+
84
+ **Prioritized, not exclusive, surfacing.** The effective sub-project is threaded through
85
+ `playbook.js` as `scopeSubproject` into `discoverProjectSkillsWithDiagnostics`. When
86
+ set, entries under `<scopeSubproject>/` lead the list (name-sorted within the group),
87
+ then root-level entries, then other-sub-project entries. **Discovery still walks
88
+ everything — no skills are dropped; only ordering changes.** An empty/unset
89
+ `scopeSubproject` reduces byte-for-byte to the historical global name-then-path sort.
90
+
91
+ **Configurable per-surface cap.** `ENGINE_DEFAULTS.projectSkillMaxFilesPerSurface`
92
+ (default **200**) caps how many skill packs / command files are read per surface
93
+ (root `.claude/skills`, each `<area>/.claude/skills`, …). `playbook.js` threads
94
+ it into the discovery `opts`; when no override is supplied the module falls back
95
+ to its own `DEFAULTS.maxFilesPerSurface` (50). It's raised well above the
96
+ fallback so large monorepo sub-projects (e.g. an `ocm/.claude/skills` with ~80 packs)
97
+ don't silently drop the most-relevant skills. Entries are alphabetically sorted
98
+ **before** the cap is applied, so the surviving subset is deterministic across
99
+ runs and OSes rather than FS-readdir-order-dependent.
100
+
101
+ **Truncation diagnostics.** `discoverProjectSkillsWithDiagnostics` returns
102
+ `{ entries, truncations }` where each truncation is `{ surface, dir, scanned,
103
+ total }` — recorded when a surface's `total` file count exceeds `scanned`
104
+ (i.e. the cap dropped entries). `playbook.js` logs a `warn` when any surface
105
+ truncates and stashes the diagnostics so `spawnAgent` can persist the detected
106
+ sub-project (`_dominantSubproject`) and truncations (`_skillDiscoveryTruncations`) onto the
107
+ dispatch record for later grounding/observability. Overflow is thus **observable**
108
+ instead of silent.
109
+
49
110
  ## The intent vocabulary (CLOSED)
50
111
 
51
112
  | Intent | What belongs here |
@@ -1153,6 +1153,52 @@ function hasReusableSignal(content) {
1153
1153
  return REUSABLE_SIGNAL_RE.test(content || '');
1154
1154
  }
1155
1155
 
1156
+ // ── Engine-alert KB dedup (W-mr3pi9de, sibling of W-mr3lokxs) ────────────────
1157
+ // Recurring engine-system alerts (e.g. the worktree-skip-live guard firing
1158
+ // across many different worktrees, or the same live-checkout-dirty incident
1159
+ // firing every tick) used to be written as full-size KB copies with -2/-3/…
1160
+ // numeric suffixes via uniquePath, multiplying storage N times per day.
1161
+ // Instead we content-hash each alert and skip writing when a same-hash entry
1162
+ // already exists in the target category. The hash is stored in the entry's
1163
+ // frontmatter (`alertHash:`) so future passes can dedup against it cheaply.
1164
+
1165
+ // Normalized, length-bounded content hash. Collapses whitespace so trivially
1166
+ // re-rendered dumps (e.g. differing timestamps) still collapse to one hash.
1167
+ function _alertContentHash(content) {
1168
+ const raw = String(content || '');
1169
+ const normalized = raw.replace(/\s+/g, ' ').trim().slice(0, 4000);
1170
+ return crypto.createHash('sha256').update(normalized + ':' + raw.length).digest('hex');
1171
+ }
1172
+
1173
+ // Read only the leading bytes of a file (frontmatter lives at the very top) so
1174
+ // scanning a directory that also holds huge legacy full-body dumps never loads
1175
+ // those multi-MB bodies into memory.
1176
+ function _readFileHead(fp, bytes = 512) {
1177
+ let fd;
1178
+ try {
1179
+ fd = fs.openSync(fp, 'r');
1180
+ const buf = Buffer.alloc(bytes);
1181
+ const n = fs.readSync(fd, buf, 0, bytes, 0);
1182
+ return buf.slice(0, n).toString('utf8');
1183
+ } catch { return ''; }
1184
+ finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* best-effort */ } } }
1185
+ }
1186
+
1187
+ // Does an existing KB entry in `dir` already carry this alertHash frontmatter?
1188
+ function _alertHashExists(dir, hash) {
1189
+ if (!hash || !dir || !fs.existsSync(dir)) return false;
1190
+ let files;
1191
+ try { files = fs.readdirSync(dir); } catch { return false; }
1192
+ const re = new RegExp(`^alertHash:\\s*${hash}\\s*$`, 'm');
1193
+ for (const f of files) {
1194
+ if (!f.endsWith('.md')) continue;
1195
+ const head = _readFileHead(path.join(dir, f));
1196
+ const fm = head.match(/^---\r?\n([\s\S]*?)\r?\n---/);
1197
+ if (fm && re.test(fm[1])) return true;
1198
+ }
1199
+ return false;
1200
+ }
1201
+
1156
1202
  /**
1157
1203
  * Build a condensed KB stub for narrowly-scoped, one-off notes instead of
1158
1204
  * duplicating the full body (issue #604 — classifyToKnowledgeBase was copying
@@ -1223,18 +1269,32 @@ async function classifyToKnowledgeBase(items, config) {
1223
1269
  // Reserve full-body KB copies for content that's actually general/reusable
1224
1270
  // (patterns, conventions, gotchas); narrowly-scoped one-off notes get a
1225
1271
  // condensed stub + link back to the archived note instead (issue #604).
1226
- const reusable = category === 'conventions' || hasReusableSignal(content);
1272
+ //
1273
+ // W-mr3pi9de — engine-authored system alerts (worktree-skip-live guard
1274
+ // fires, dirty-tree refusals, blocked dispatches, managed-spawn failures)
1275
+ // are operational noise, NEVER durable reusable knowledge. Force them
1276
+ // non-reusable regardless of category or any incidental heading-shaped
1277
+ // line in the body that would otherwise trip hasReusableSignal, and
1278
+ // content-hash-dedup recurring identical/near-identical incidents instead
1279
+ // of appending -2/-3 full copies with a colliding title.
1280
+ const isSystemAlert = shared.isEngineSystemAlert(item.name, content);
1281
+ const reusable = !isSystemAlert && (category === 'conventions' || hasReusableSignal(content));
1227
1282
  const archiveRelPath = `notes/archive/${dateStamp()}-${item.name}`;
1228
1283
  const kbBody = reusable
1229
1284
  ? content
1230
1285
  : buildCondensedKbBody(content, titleMatch ? titleMatch[0] : null, archiveRelPath);
1231
1286
 
1232
- const frontmatter = `---\nsource: ${item.name}\nagent: ${agent}\ncategory: ${category}\ndate: ${dateStamp()}\nreusable: ${reusable}\n---\n\n`;
1233
- try {
1234
- safeWrite(kbPath, frontmatter + kbBody);
1235
- classified++;
1236
- } catch (err) {
1237
- log('warn', `Failed to classify ${item.name} to knowledge base: ${err.message}`);
1287
+ const alertHash = isSystemAlert ? _alertContentHash(content) : null;
1288
+ if (isSystemAlert && _alertHashExists(categoryDirs[category], alertHash)) {
1289
+ log('info', `KB dedup: skipping recurring engine-alert ${item.name} — duplicate of an existing knowledge/${category} entry`);
1290
+ } else {
1291
+ const frontmatter = `---\nsource: ${item.name}\nagent: ${agent}\ncategory: ${category}\ndate: ${dateStamp()}\nreusable: ${reusable}${isSystemAlert ? `\nalertHash: ${alertHash}` : ''}\n---\n\n`;
1292
+ try {
1293
+ safeWrite(kbPath, frontmatter + kbBody);
1294
+ classified++;
1295
+ } catch (err) {
1296
+ log('warn', `Failed to classify ${item.name} to knowledge base: ${err.message}`);
1297
+ }
1238
1298
  }
1239
1299
 
1240
1300
  // Per-agent memory routing — strict superset of broadcast consolidation.
@@ -1397,4 +1457,6 @@ module.exports = {
1397
1457
  NOTES_MAX_BYTES,
1398
1458
  hasReusableSignal,
1399
1459
  buildCondensedKbBody,
1460
+ _alertContentHash,
1461
+ _alertHashExists,
1400
1462
  };
@@ -62,6 +62,8 @@ const DEFAULTS = {
62
62
  docsScanMaxBytes: 32 * 1024, // CLAUDE.md / copilot-instructions.md top window
63
63
  walltimeMs: 250, // bail rather than block dispatch on pathological FS
64
64
  maxAreas: 50, // cap how many <area>/ dirs we'll probe for nested .claude
65
+ dominantSubprojectMinRatio: 0.6, // detectDominantSubproject: top sub-project must hold >= this share of sub-project-matched files
66
+ dominantSubprojectMinFiles: 2, // detectDominantSubproject: top sub-project must have >= this many matched files
65
67
  };
66
68
 
67
69
  // Intent vocabulary — CLOSED. Each entry maps an intent to the keyword/regex
@@ -169,21 +171,44 @@ function classifyIntents({ name = '', description = '', extra = '', explicit = [
169
171
  return intents;
170
172
  }
171
173
 
174
+ // Record that a scanned surface exceeded opts.maxFilesPerSurface so callers can
175
+ // surface an overflow diagnostic instead of silently dropping entries.
176
+ function _recordTruncation(truncations, surface, projectPath, dir, scanned, total) {
177
+ if (!Array.isArray(truncations)) return;
178
+ if (total <= scanned) return;
179
+ truncations.push({
180
+ surface,
181
+ dir: path.relative(projectPath, dir).split(path.sep).join('/'),
182
+ scanned,
183
+ total,
184
+ });
185
+ }
186
+
172
187
  // Walk <baseDir>/.claude/skills/*\/SKILL.md
173
- function _discoverSkillsAt(baseDir, projectPath, opts, deadline, originLabel) {
188
+ function _discoverSkillsAt(baseDir, projectPath, opts, deadline, originLabel, truncations) {
174
189
  const out = [];
175
190
  const skillsDir = path.join(baseDir, '.claude', 'skills');
176
191
  let entries;
177
192
  try { entries = fs.readdirSync(skillsDir, { withFileTypes: true }); } catch { return out; }
193
+ // Stable alphabetical sort by entry name BEFORE applying the
194
+ // maxFilesPerSurface cap so the surviving subset is deterministic and
195
+ // reproducible across runs / OSes (raw readdir order is FS-dependent, so
196
+ // capping pre-sort silently dropped a non-deterministic slice).
197
+ entries.sort((a, b) => a.name.localeCompare(b.name));
198
+ const cap = opts.maxFilesPerSurface;
178
199
  let scanned = 0;
200
+ let total = 0;
179
201
  for (const ent of entries) {
180
- if (scanned >= opts.maxFilesPerSurface) break;
181
202
  if (_now() > deadline) break;
182
203
  if (!ent.isDirectory()) continue;
183
204
  const skillPath = path.join(skillsDir, ent.name, 'SKILL.md');
184
205
  let stat;
185
206
  try { stat = fs.statSync(skillPath); } catch { continue; }
186
207
  if (!stat.isFile()) continue;
208
+ total += 1;
209
+ // Beyond the cap we still count `total` (for an accurate overflow
210
+ // diagnostic) but stop reading/emitting entries.
211
+ if (scanned >= cap) continue;
187
212
  scanned += 1;
188
213
  const head = _safeReadHead(skillPath, opts.maxBytesPerFile);
189
214
  if (!head) continue;
@@ -202,21 +227,28 @@ function _discoverSkillsAt(baseDir, projectPath, opts, deadline, originLabel) {
202
227
  _originLabel: originLabel,
203
228
  });
204
229
  }
230
+ _recordTruncation(truncations, 'skill', projectPath, skillsDir, scanned, total);
205
231
  return out;
206
232
  }
207
233
 
208
234
  // Walk <baseDir>/.claude/commands/*.md
209
- function _discoverCommandsAt(baseDir, projectPath, opts, deadline, originLabel) {
235
+ function _discoverCommandsAt(baseDir, projectPath, opts, deadline, originLabel, truncations) {
210
236
  const out = [];
211
237
  const cmdDir = path.join(baseDir, '.claude', 'commands');
212
238
  let entries;
213
239
  try { entries = fs.readdirSync(cmdDir, { withFileTypes: true }); } catch { return out; }
240
+ // Stable alphabetical sort by entry name BEFORE applying the
241
+ // maxFilesPerSurface cap (see _discoverSkillsAt for the determinism rationale).
242
+ entries.sort((a, b) => a.name.localeCompare(b.name));
243
+ const cap = opts.maxFilesPerSurface;
214
244
  let scanned = 0;
245
+ let total = 0;
215
246
  for (const ent of entries) {
216
- if (scanned >= opts.maxFilesPerSurface) break;
217
247
  if (_now() > deadline) break;
218
248
  if (!ent.isFile()) continue;
219
249
  if (!/\.md$/i.test(ent.name)) continue;
250
+ total += 1;
251
+ if (scanned >= cap) continue;
220
252
  scanned += 1;
221
253
  const base = ent.name.replace(/\.md$/i, '');
222
254
  const cmdPath = path.join(cmdDir, ent.name);
@@ -232,6 +264,7 @@ function _discoverCommandsAt(baseDir, projectPath, opts, deadline, originLabel)
232
264
  _originLabel: originLabel,
233
265
  });
234
266
  }
267
+ _recordTruncation(truncations, 'command', projectPath, cmdDir, scanned, total);
235
268
  return out;
236
269
  }
237
270
 
@@ -306,25 +339,138 @@ function _listAreas(projectPath, opts, deadline) {
306
339
  }
307
340
 
308
341
  /**
342
+ * Pure dominant-sub-project (area) detector.
343
+ *
344
+ * Given a set of changed paths, decide whether a single first-level project
345
+ * sub-project (e.g. `ocm/`, `loop/`, `officemobile/`) dominates the diff strongly
346
+ * enough that skill discovery / harness surfacing should be scoped to it.
347
+ * Reuses `_listAreas` for the authoritative set of valid sub-projects (sorted,
348
+ * dot-dir / node_modules filtered, capped at opts.maxAreas) so a stray first
349
+ * path segment that is NOT a real top-level directory never counts as a sub-project.
350
+ *
351
+ * Purity: no engine state, no fs beyond the single `_listAreas` readdir. The
352
+ * result is fully determined by (projectPath's first-level dirs, changedPaths,
353
+ * opts) — unit-testable against a fixture.
354
+ *
355
+ * @param {object} args
356
+ * @param {string} args.projectPath — absolute path to the project worktree / checkout
357
+ * @param {string[]} args.changedPaths — repo-relative changed file paths (any separator)
358
+ * @param {object} [args.opts] — override thresholds / caps (mostly for tests)
359
+ * @returns {{subproject:string|null, matched:number, total:number, confident:boolean}}
360
+ * total — count of changed paths considered
361
+ * matched — count of changed paths that fall under a known sub-project
362
+ * subproject — the dominant sub-project when `confident`, else null (flat-discovery fallback)
363
+ * confident — top sub-project holds >= minRatio of matched files AND >= minFiles files
364
+ */
365
+ function detectDominantSubproject(args) {
366
+ const projectPath = args && args.projectPath;
367
+ const changedPaths = args && args.changedPaths;
368
+ const opts = Object.assign({}, DEFAULTS, (args && args.opts) || {});
369
+
370
+ const total = Array.isArray(changedPaths) ? changedPaths.length : 0;
371
+ if (!projectPath || typeof projectPath !== 'string' || total === 0) {
372
+ return { subproject: null, matched: 0, total, confident: false };
373
+ }
374
+
375
+ const deadline = _now() + Math.max(1, opts.walltimeMs);
376
+ const subprojects = new Set(_listAreas(projectPath, opts, deadline));
377
+ if (subprojects.size === 0) {
378
+ return { subproject: null, matched: 0, total, confident: false };
379
+ }
380
+
381
+ // Count changed files per known sub-project, keyed on the normalized first segment.
382
+ const counts = new Map();
383
+ let matched = 0;
384
+ for (const raw of changedPaths) {
385
+ if (typeof raw !== 'string' || raw.length === 0) continue;
386
+ // Normalize to POSIX and strip leading ./ and / so the first segment is
387
+ // the true top-level directory.
388
+ let p = raw.replace(/\\/g, '/');
389
+ while (p.startsWith('./')) p = p.slice(2);
390
+ while (p.startsWith('/')) p = p.slice(1);
391
+ const seg = p.split('/')[0];
392
+ if (!seg || !subprojects.has(seg)) continue;
393
+ matched += 1;
394
+ counts.set(seg, (counts.get(seg) || 0) + 1);
395
+ }
396
+
397
+ if (matched === 0) {
398
+ return { subproject: null, matched: 0, total, confident: false };
399
+ }
400
+
401
+ // Dominant sub-project = highest count; ties broken alphabetically for determinism.
402
+ let topSubproject = null;
403
+ let topCount = -1;
404
+ for (const [sub, count] of counts) {
405
+ if (count > topCount || (count === topCount && (topSubproject === null || sub.localeCompare(topSubproject) < 0))) {
406
+ topSubproject = sub;
407
+ topCount = count;
408
+ }
409
+ }
410
+
411
+ const minRatio = typeof opts.dominantSubprojectMinRatio === 'number' ? opts.dominantSubprojectMinRatio : DEFAULTS.dominantSubprojectMinRatio;
412
+ const minFiles = typeof opts.dominantSubprojectMinFiles === 'number' ? opts.dominantSubprojectMinFiles : DEFAULTS.dominantSubprojectMinFiles;
413
+ const confident = topCount >= minFiles && (topCount / matched) >= minRatio;
414
+
415
+ return { subproject: confident ? topSubproject : null, matched, total, confident };
416
+ }
417
+
418
+ // Normalize a caller-supplied scopeSubproject into a bare POSIX first-level dir name
419
+ // (backslashes → slashes, leading/trailing slashes + whitespace stripped).
420
+ // Returns '' when unset/empty so the sort can cheaply skip scope grouping.
421
+ function _normalizeScopeSubproject(scopeSubproject) {
422
+ if (typeof scopeSubproject !== 'string') return '';
423
+ return scopeSubproject.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '').trim();
424
+ }
425
+
426
+ // The project sub-project an entry's (repo-relative POSIX) path lives under, or
427
+ // null for root-level entries. Sub-project skill/command paths look like
428
+ // `<area>/.claude/…`; root skills/commands (`.claude/…`) and root docs
429
+ // (`CLAUDE.md`, `.github/copilot-instructions.md`) have no owning sub-project.
430
+ function _entrySubproject(entryPath) {
431
+ const m = /^([^/]+)\/\.claude\//.exec(String(entryPath || '').replace(/\\/g, '/'));
432
+ return m ? m[1] : null;
433
+ }
434
+
435
+ // Scope-ordering group for a scopeSubproject-aware sort. Lower ranks lead the list:
436
+ // 0 = entry is under <scopeSubproject>/, 1 = root-level, 2 = other-sub-project.
437
+ function _scopeGroup(entryPath, scopeSubproject) {
438
+ const p = String(entryPath || '').replace(/\\/g, '/');
439
+ if (scopeSubproject && (p === scopeSubproject || p.startsWith(scopeSubproject + '/'))) return 0;
440
+ return _entrySubproject(p) === null ? 1 : 2;
441
+ }
442
+
443
+ /**
444
+ * Diagnostics-returning discovery. Same walk as discoverProjectSkills but also
445
+ * reports per-surface overflow records so callers can tell when a directory
446
+ * exceeded opts.maxFilesPerSurface (and therefore had entries dropped) instead
447
+ * of silently losing them.
448
+ *
309
449
  * @param {object} args
310
450
  * @param {string} args.projectPath — absolute path to the project worktree / checkout
451
+ * @param {string} [args.scopeSubproject] — when set/non-empty, surface entries under
452
+ * `<scopeSubproject>/` FIRST (name-sorted within group), then root-level entries,
453
+ * then other-sub-project entries. Discovery still walks everything (no skills
454
+ * dropped); only ordering changes. Unset/empty ⇒ today's global name-sort.
311
455
  * @param {object} [args.opts] — override defaults (mostly for tests)
312
- * @returns {Array<{kind:string,name:string,path:string,oneLineDescription:string,intents:string[]}>}
456
+ * @returns {{entries:Array<{kind:string,name:string,path:string,oneLineDescription:string,intents:string[]}>, truncations:Array<{surface:string,dir:string,scanned:number,total:number}>}}
313
457
  */
314
- function discoverProjectSkills(args) {
458
+ function discoverProjectSkillsWithDiagnostics(args) {
315
459
  const projectPath = args && args.projectPath;
316
- if (!projectPath || typeof projectPath !== 'string') return [];
317
- try { if (!fs.statSync(projectPath).isDirectory()) return []; } catch { return []; }
460
+ if (!projectPath || typeof projectPath !== 'string') return { entries: [], truncations: [] };
461
+ try { if (!fs.statSync(projectPath).isDirectory()) return { entries: [], truncations: [] }; } catch { return { entries: [], truncations: [] }; }
318
462
 
463
+ const scopeSubproject = _normalizeScopeSubproject(args.scopeSubproject);
319
464
  const opts = Object.assign({}, DEFAULTS, args.opts || {});
320
465
  const deadline = _now() + Math.max(1, opts.walltimeMs);
321
466
 
322
467
  const seenKey = new Set(); // dedupe across surfaces by `${kind}:${name}`
323
468
  const seenSlash = new Set();
324
469
  const all = [];
470
+ const truncations = [];
325
471
 
326
472
  // 1. Root-level skills.
327
- for (const entry of _discoverSkillsAt(projectPath, projectPath, opts, deadline, 'root')) {
473
+ for (const entry of _discoverSkillsAt(projectPath, projectPath, opts, deadline, 'root', truncations)) {
328
474
  const key = `skill:${entry.name}`;
329
475
  if (seenKey.has(key)) continue;
330
476
  seenKey.add(key);
@@ -332,7 +478,7 @@ function discoverProjectSkills(args) {
332
478
  }
333
479
 
334
480
  // 2. Root-level commands.
335
- for (const entry of _discoverCommandsAt(projectPath, projectPath, opts, deadline, 'root')) {
481
+ for (const entry of _discoverCommandsAt(projectPath, projectPath, opts, deadline, 'root', truncations)) {
336
482
  const key = `command:${entry.name}`;
337
483
  if (seenKey.has(key)) continue;
338
484
  seenKey.add(key);
@@ -348,7 +494,7 @@ function discoverProjectSkills(args) {
348
494
  for (const area of areas) {
349
495
  if (_now() > deadline) break;
350
496
  const areaBase = path.join(projectPath, area);
351
- for (const entry of _discoverSkillsAt(areaBase, projectPath, opts, deadline, `area:${area}`)) {
497
+ for (const entry of _discoverSkillsAt(areaBase, projectPath, opts, deadline, `area:${area}`, truncations)) {
352
498
  const key = `skill:${entry.name}`;
353
499
  if (seenKey.has(key)) continue;
354
500
  seenKey.add(key);
@@ -360,7 +506,7 @@ function discoverProjectSkills(args) {
360
506
  // half-scanned area: doing so drops commands while keeping skills from
361
507
  // the same area, causing test/prod divergence under load (PR-82 blind spot
362
508
  // regression, CI failure yemi33#28135287117).
363
- for (const entry of _discoverCommandsAt(areaBase, projectPath, opts, deadline, `area:${area}`)) {
509
+ for (const entry of _discoverCommandsAt(areaBase, projectPath, opts, deadline, `area:${area}`, truncations)) {
364
510
  const key = `command:${entry.name}`;
365
511
  if (seenKey.has(key)) continue;
366
512
  seenKey.add(key);
@@ -379,15 +525,39 @@ function discoverProjectSkills(args) {
379
525
  all.push(entry);
380
526
  }
381
527
 
382
- // Deterministic ordering: alphabetical by name, then by path as tiebreaker
383
- // so prompt rendering is stable across runs and OSes (readdir order varies).
528
+ // Deterministic ordering. When a scopeSubproject is set, entries under
529
+ // `<scopeSubproject>/` lead the list, then root-level, then other-sub-project entries;
530
+ // within each group the original name-then-path sort applies. When scopeSubproject
531
+ // is empty the group ranks are all equal so this reduces byte-for-byte to the
532
+ // historical global name-then-path sort (no regression for existing callers).
384
533
  all.sort((a, b) => {
534
+ if (scopeSubproject) {
535
+ const ga = _scopeGroup(a.path, scopeSubproject);
536
+ const gb = _scopeGroup(b.path, scopeSubproject);
537
+ if (ga !== gb) return ga - gb;
538
+ }
385
539
  if (a.name !== b.name) return a.name.localeCompare(b.name);
386
540
  return String(a.path || '').localeCompare(String(b.path || ''));
387
541
  });
388
542
 
389
543
  // Strip the internal _originLabel before returning (debugging-only).
390
- return all.map(({ _originLabel, ...rest }) => rest); // eslint-disable-line no-unused-vars
544
+ const entries = all.map(({ _originLabel, ...rest }) => rest); // eslint-disable-line no-unused-vars
545
+ return { entries, truncations };
546
+ }
547
+
548
+ /**
549
+ * @param {object} args
550
+ * @param {string} args.projectPath — absolute path to the project worktree / checkout
551
+ * @param {string} [args.scopeSubproject] — when set/non-empty, surface entries under
552
+ * `<scopeSubproject>/` first, then root-level, then other-sub-project entries (see
553
+ * discoverProjectSkillsWithDiagnostics). Unset/empty ⇒ global name-sort.
554
+ * @param {object} [args.opts] — override defaults (mostly for tests)
555
+ * @returns {Array<{kind:string,name:string,path:string,oneLineDescription:string,intents:string[]}>}
556
+ */
557
+ function discoverProjectSkills(args) {
558
+ // Delegates to the diagnostics variant and returns only the entries array so
559
+ // existing array-returning callers keep working unchanged.
560
+ return discoverProjectSkillsWithDiagnostics(args).entries;
391
561
  }
392
562
 
393
563
  /**
@@ -478,6 +648,7 @@ function renderReviewSkillsBlock(entries) {
478
648
 
479
649
  module.exports = {
480
650
  discoverProjectSkills,
651
+ discoverProjectSkillsWithDiagnostics,
481
652
  filterByIntents,
482
653
  renderProjectSkillsBlock,
483
654
  classifyIntents,
@@ -494,5 +665,9 @@ module.exports = {
494
665
  _parseExplicitIntents,
495
666
  _extractSlashCommandsFromDoc,
496
667
  _listAreas,
668
+ detectDominantSubproject,
669
+ _normalizeScopeSubproject,
670
+ _entrySubproject,
671
+ _scopeGroup,
497
672
  },
498
673
  };
@@ -2943,6 +2943,18 @@ function recordPrNoOpFixAttempt(target, cause, source, dispatchItem, branchChang
2943
2943
  return target._noOpFixes[cause];
2944
2944
  }
2945
2945
 
2946
+ // #671 — Clears the `_buildFixIneffective` ("infra issue suspected") pause so
2947
+ // BUILD_FAILURE auto-fix dispatch can resume. Distinct from
2948
+ // `clearPrNoOpFixAttempt` (below) because this pause is headSha-keyed rather
2949
+ // than cause-keyed — see `recordBuildFixIneffective` / `shared.isBuildFixIneffectivePaused`.
2950
+ // Returns true when a record was actually removed so callers (the dashboard
2951
+ // resume endpoint) can distinguish "cleared" from "nothing to clear".
2952
+ function clearBuildFixIneffective(target) {
2953
+ if (!target?._buildFixIneffective) return false;
2954
+ delete target._buildFixIneffective;
2955
+ return true;
2956
+ }
2957
+
2946
2958
  function clearPrNoOpFixAttempt(target, cause) {
2947
2959
  if (!target?._noOpFixes || !target._noOpFixes[cause]) return;
2948
2960
  delete target._noOpFixes[cause];
@@ -6821,6 +6833,9 @@ module.exports = {
6821
6833
  // Issue #2969 — exported so dashboard.js can clear a paused cause from the
6822
6834
  // resume endpoint.
6823
6835
  clearPrNoOpFixAttempt,
6836
+ // Issue #671 — exported so dashboard.js can clear the build-fix-ineffective
6837
+ // ("infra issue suspected") pause from the resume endpoint.
6838
+ clearBuildFixIneffective,
6824
6839
  // W-mpx44p05000ze8d8 — exported so engine/ado.js + engine/github.js can
6825
6840
  // drop stale `_noOpFixes` records during their PR status poll, and so
6826
6841
  // unit tests can exercise the sweep helper directly.