@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/docs/typography.md +21 -2
- package/dashboard/js/render-other.js +17 -1
- package/dashboard/js/render-prs.js +74 -18
- package/dashboard/slim/body.html +6 -1
- package/dashboard/slim/js/modals-tiles.js +23 -116
- package/dashboard/slim/styles.css +11 -28
- package/dashboard/styles.css +18 -8
- package/dashboard.js +58 -0
- package/docs/harness-transparency.md +17 -1
- package/docs/project-skills.md +61 -0
- package/engine/consolidation.js +69 -7
- package/engine/discover-project-skills.js +190 -15
- package/engine/lifecycle.js +15 -0
- package/engine/live-checkout.js +38 -0
- package/engine/playbook.js +31 -1
- package/engine/queries.js +15 -0
- package/engine/shared.js +83 -1
- package/engine.js +132 -16
- package/package.json +1 -1
- package/prompts/cc-system.md +15 -4
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
|
package/docs/project-skills.md
CHANGED
|
@@ -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 |
|
package/engine/consolidation.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
|
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
|
|
383
|
-
//
|
|
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
|
-
|
|
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
|
};
|
package/engine/lifecycle.js
CHANGED
|
@@ -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.
|