@yemi33/minions 0.1.2320 → 0.1.2322
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-prs.js +24 -10
- package/dashboard/slim/styles.css +1 -1
- package/dashboard/styles.css +18 -8
- package/dashboard.js +7 -2
- package/docs/harness-transparency.md +17 -1
- package/docs/live-checkout-mode.md +16 -0
- package/docs/project-skills.md +61 -0
- package/engine/discover-project-skills.js +190 -15
- package/engine/live-checkout.js +17 -0
- package/engine/playbook.js +31 -1
- package/engine/pr-action.js +28 -10
- package/engine/queries.js +15 -0
- package/engine/shared.js +42 -0
- package/engine.js +131 -15
- package/package.json +1 -1
- package/prompts/cc-system.md +15 -4
|
@@ -41,7 +41,7 @@ two stay in lockstep.
|
|
|
41
41
|
| Token | Value | Common use |
|
|
42
42
|
| ------------------ | ----- | --------------------------------------------------------- |
|
|
43
43
|
| `--text-xs` | 10px | Smallest legitimate text; sidebar badges, count pills |
|
|
44
|
-
| `--text-sm` |
|
|
44
|
+
| `--text-sm` | 12px | Status badges, chip / tag labels |
|
|
45
45
|
| `--text-base` | 12px | Captions, secondary metadata, dense table cells |
|
|
46
46
|
| `--text-md` | 13px | Default for slim chat / detail tables / button labels |
|
|
47
47
|
| `--text-lg` | 14px | Body text, modal textarea, card titles |
|
|
@@ -57,6 +57,25 @@ two stay in lockstep.
|
|
|
57
57
|
> token that breaks the "all px" rule and is explicitly allowlisted in
|
|
58
58
|
> the tripwire.
|
|
59
59
|
|
|
60
|
+
### 12px readability floor (W-mr3xgejt0008d3a5)
|
|
61
|
+
|
|
62
|
+
`--text-sm` was raised from an 11px base to **12px** so it clears the 12px
|
|
63
|
+
readability floor in both stylesheets: classic renders it at `12 × 1.05 =
|
|
64
|
+
12.6px`, slim at `12 × 1 = 12px`. `--text-xs` (10px base) stays below the
|
|
65
|
+
floor and is reserved for the very smallest chrome. To lift the
|
|
66
|
+
highest-signal small-text callsites off `--text-xs`, these selectors were
|
|
67
|
+
reassigned to `--text-sm`:
|
|
68
|
+
|
|
69
|
+
- `.sidebar-count` — primary nav count pill
|
|
70
|
+
- `.dispatch-stuck` — uppercase red "stuck" status badge
|
|
71
|
+
- `.pl-node-meta` — muted plan-node secondary text
|
|
72
|
+
- `.error-details-btn` — uppercase error-details button label
|
|
73
|
+
|
|
74
|
+
The `--text-sm` selectors already on the token (`.table th`, `.pr-table th`,
|
|
75
|
+
`.token-agent-table th`, `.status-badge`, `.sidebar-link`, slim
|
|
76
|
+
`.tile-item-subtle`) now clear the floor automatically via the token bump —
|
|
77
|
+
no per-callsite change needed.
|
|
78
|
+
|
|
60
79
|
## Role aliases (preferred for new code)
|
|
61
80
|
|
|
62
81
|
Use the role token when adding new UI. It survives a future size-scale
|
|
@@ -70,7 +89,7 @@ redesign without callsite edits.
|
|
|
70
89
|
| `--text-body` | `--text-lg` (14px) | Default body text, paragraphs |
|
|
71
90
|
| `--text-meta` | `--text-md` (13px) | Captions, timestamps, secondary metadata |
|
|
72
91
|
| `--text-caption` | `--text-base` (12px)| Small labels, table cells |
|
|
73
|
-
| `--text-micro` | `--text-sm` (
|
|
92
|
+
| `--text-micro` | `--text-sm` (12px) | Chip / tag pill labels |
|
|
74
93
|
| `--text-code` | (`0.9em`) | Inline code |
|
|
75
94
|
|
|
76
95
|
## Utility classes
|
|
@@ -49,8 +49,14 @@ async function fetchPullRequestsFromDisk(projects) {
|
|
|
49
49
|
out.push(pr);
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
-
// Same sort the engine applies:
|
|
52
|
+
// Same sort the engine applies (engine/queries.js#getPullRequests): group
|
|
53
|
+
// terminal (merged/abandoned/closed) PRs to the bottom, then most recent
|
|
54
|
+
// created first within each group, falling back to id.
|
|
55
|
+
const _isTerminalPr = (pr) => pr.status === 'merged' || pr.status === 'abandoned' || pr.status === 'closed';
|
|
53
56
|
out.sort((a, b) => {
|
|
57
|
+
const at = _isTerminalPr(a) ? 1 : 0;
|
|
58
|
+
const bt = _isTerminalPr(b) ? 1 : 0;
|
|
59
|
+
if (at !== bt) return at - bt;
|
|
54
60
|
const ac = a.created || '';
|
|
55
61
|
const bc = b.created || '';
|
|
56
62
|
if (ac && bc) return bc.localeCompare(ac);
|
|
@@ -264,15 +270,23 @@ function prRow(pr) {
|
|
|
264
270
|
// ellipsis-truncated content reveals the full text. Cell tags stay bare so
|
|
265
271
|
// the header-to-cell count assertion in test/unit.test.js continues to
|
|
266
272
|
// balance.
|
|
273
|
+
// First-column secondary project label (W-mr3mowg9) — mirrors the pr-desc
|
|
274
|
+
// secondary-text pattern used in the Title cell so reviewers can see at a
|
|
275
|
+
// glance which project a PR belongs to. `_project` is stamped by the engine
|
|
276
|
+
// enrichment / getAllPrs stamping (see top-of-file note + line ~33).
|
|
277
|
+
const projectName = pr._project ? String(pr._project) : '';
|
|
278
|
+
const projectLabelHtml = projectName
|
|
279
|
+
? '<div class="pr-desc pr-id-project" title="' + escapeHtml(projectName) + '">' + escapeHtml(projectName) + '</div>'
|
|
280
|
+
: '';
|
|
267
281
|
return '<tr>' +
|
|
268
|
-
'<td><span class="pr-id" title="' + escapeHtml(String(prId)) + '">' + escapeHtml(prShortId(String(prId))) + '</span
|
|
282
|
+
'<td><span class="pr-id" title="' + escapeHtml(String(prId)) + '">' + escapeHtml(prShortId(String(prId))) + '</span>' + projectLabelHtml + '</td>' +
|
|
269
283
|
'<td><a class="pr-title" title="' + escapeHtml(titleText) + '" href="' + escapeHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + escapeHtml(titleText) + '</a>' + followupChip + pausedChips + (descPreview ? '<div class="pr-desc" title="' + escapeHtml(descPreview) + '">' + escapeHtml(descPreview.length > 120 ? descPreview.slice(0, 120) + '...' : descPreview) + '</div>' : '') + '</td>' +
|
|
270
|
-
'<td><span class="pr-
|
|
271
|
-
'<td><span class="' + branchClass + '" title="' + escapeHtml(branchError || branchLabel) + '">' + escapeHtml(branchLabel) + '</span>' + pendingReasonHtml + '</td>' +
|
|
284
|
+
'<td><span class="pr-badge ' + statusClass + '" title="' + escapeHtml(statusLabel) + '">' + escapeHtml(statusLabel) + '</span></td>' +
|
|
272
285
|
'<td><span class="pr-badge ' + reviewClass + '" title="' + escapeHtml(reviewTitle || reviewLabel) + '">' + escapeHtml(reviewLabel) + '</span></td>' +
|
|
286
|
+
'<td><span class="' + branchClass + '" title="' + escapeHtml(branchError || branchLabel) + '">' + escapeHtml(branchLabel) + '</span>' + pendingReasonHtml + '</td>' +
|
|
287
|
+
'<td><span class="pr-agent" title="' + escapeHtml(agentText) + '">' + escapeHtml(agentText) + '</span></td>' +
|
|
273
288
|
'<td>' + reviewerCell + '</td>' +
|
|
274
289
|
'<td><span class="pr-badge ' + buildClass + '" title="' + escapeHtml(buildTitle || buildLabel) + '">' + escapeHtml(buildLabel) + '</span></td>' +
|
|
275
|
-
'<td><span class="pr-badge ' + statusClass + '" title="' + escapeHtml(statusLabel) + '">' + escapeHtml(statusLabel) + '</span></td>' +
|
|
276
290
|
'<td>' + (observeBtn || '<span style="color:var(--muted);font-size:var(--text-base)">—</span>') + '</td>' +
|
|
277
291
|
'<td><span class="pr-date" title="' + escapeHtml(createdLabel) + '">' + escapeHtml(createdLabel) + '</span></td>' +
|
|
278
292
|
'<td><button class="pr-info-pill" data-pr-id="' + escapeHtml(String(prId)) + '" onclick="event.stopPropagation();openPrDetail(this.dataset.prId)" title="Open PR detail (in-stack modal)">↗</button>' +
|
|
@@ -286,14 +300,14 @@ function prRow(pr) {
|
|
|
286
300
|
// the viewport (sticky scrollbar — see styles.css).
|
|
287
301
|
const PRS_COLGROUP =
|
|
288
302
|
'<colgroup>' +
|
|
289
|
-
'<col style="width:
|
|
303
|
+
'<col style="width:130px">' + // PR id (short label: `gh #481` / `ado #5383607`) + project label
|
|
290
304
|
'<col style="width:320px">' + // Title
|
|
291
|
-
'<col style="width:
|
|
292
|
-
'<col style="width:200px">' + // Branch
|
|
305
|
+
'<col style="width:110px">' + // Status
|
|
293
306
|
'<col style="width:130px">' + // Review
|
|
307
|
+
'<col style="width:200px">' + // Branch
|
|
308
|
+
'<col style="width:140px">' + // Agent
|
|
294
309
|
'<col style="width:140px">' + // Signed Off By
|
|
295
310
|
'<col style="width:130px">' + // Build
|
|
296
|
-
'<col style="width:110px">' + // Status
|
|
297
311
|
'<col style="width:100px">' + // Observe
|
|
298
312
|
'<col style="width:130px">' + // Created
|
|
299
313
|
'<col style="width:80px">' + // Actions (info-pill + remove)
|
|
@@ -303,7 +317,7 @@ function prTableHtml(rows) {
|
|
|
303
317
|
return '<div class="pr-table-wrap pr-table-wrap--prs"><table class="pr-table pr-table--prs">' +
|
|
304
318
|
PRS_COLGROUP +
|
|
305
319
|
'<thead><tr>' +
|
|
306
|
-
'<th>PR</th><th>Title</th><th>
|
|
320
|
+
'<th>PR</th><th>Title</th><th>Status</th><th>Review</th><th>Branch</th><th>Agent</th><th>Signed Off By</th><th>Build</th><th>Observe</th><th>Created</th><th></th>' +
|
|
307
321
|
'</tr></thead><tbody>' + rows + '</tbody></table></div>';
|
|
308
322
|
}
|
|
309
323
|
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
the slim UI at its current text size — the global bump is excluded
|
|
43
43
|
here by design. Do NOT raise this value to match main. */
|
|
44
44
|
--minions-text-scale: 1;
|
|
45
|
-
--text-xs: calc(10px * var(--minions-text-scale)); --text-sm: calc(
|
|
45
|
+
--text-xs: calc(10px * var(--minions-text-scale)); --text-sm: calc(12px * var(--minions-text-scale)); --text-base: calc(12px * var(--minions-text-scale));
|
|
46
46
|
--text-md: calc(13px * var(--minions-text-scale)); --text-lg: calc(14px * var(--minions-text-scale)); --text-xl: calc(16px * var(--minions-text-scale)); --text-2xl: calc(18px * var(--minions-text-scale));
|
|
47
47
|
--text-stat: calc(22px * var(--minions-text-scale)); --text-stat-lg: calc(28px * var(--minions-text-scale)); --text-display: calc(32px * var(--minions-text-scale));
|
|
48
48
|
--text-role-display: var(--text-display);
|
package/dashboard/styles.css
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
its own (un-scaled) factor of 1, so the slim UI is excluded from the
|
|
29
29
|
bump by construction. */
|
|
30
30
|
--minions-text-scale: 1.05;
|
|
31
|
-
--text-xs: calc(10px * var(--minions-text-scale)); --text-sm: calc(
|
|
31
|
+
--text-xs: calc(10px * var(--minions-text-scale)); --text-sm: calc(12px * var(--minions-text-scale)); --text-base: calc(12px * var(--minions-text-scale));
|
|
32
32
|
--text-md: calc(13px * var(--minions-text-scale)); --text-lg: calc(14px * var(--minions-text-scale)); --text-xl: calc(16px * var(--minions-text-scale)); --text-2xl: calc(18px * var(--minions-text-scale));
|
|
33
33
|
--text-stat: calc(22px * var(--minions-text-scale)); --text-stat-lg: calc(28px * var(--minions-text-scale)); --text-display: calc(32px * var(--minions-text-scale));
|
|
34
34
|
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
--text-body: var(--text-lg); /* 14px — default body text */
|
|
46
46
|
--text-meta: var(--text-md); /* 13px — captions, timestamps, secondary metadata */
|
|
47
47
|
--text-caption: var(--text-base); /* 12px — small labels, table cells */
|
|
48
|
-
--text-micro: var(--text-sm); /*
|
|
48
|
+
--text-micro: var(--text-sm); /* 12px — chip / tag pill labels */
|
|
49
49
|
--text-code: 0.9em; /* inline code; relative so it scales with the surrounding text */
|
|
50
50
|
|
|
51
51
|
/* Border radius */
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
.sidebar-link .notif-badge.done { top: 50%; right: 6px; transform: translateY(-50%); width: 6px; height: 6px; }
|
|
108
108
|
.sidebar-link:hover { color: var(--text); background: var(--surface2); }
|
|
109
109
|
.sidebar-link.active { color: var(--blue); border-left-color: var(--blue); background: var(--surface2); font-weight: 600; }
|
|
110
|
-
.sidebar-count { font-size: var(--text-
|
|
110
|
+
.sidebar-count { font-size: var(--text-sm); color: var(--muted); background: var(--surface2); padding: 1px 5px; border-radius: 8px; min-width: 16px; text-align: center; }
|
|
111
111
|
.page-content { flex: 1; overflow-y: auto; min-width: 0; }
|
|
112
112
|
.page { display: none; }
|
|
113
113
|
.page.active { display: block; }
|
|
@@ -264,7 +264,7 @@
|
|
|
264
264
|
.pl-node-box.condition { border-style: dashed; }
|
|
265
265
|
.pl-node-box.pl-node-stop { border-style: dotted; border-color: var(--muted); opacity: 0.7; }
|
|
266
266
|
.pl-node-arrow { display: flex; align-items: center; padding: 0 2px; color: var(--muted); font-size: var(--text-md); flex-shrink: 0; margin-top: 8px; }
|
|
267
|
-
.pl-node-meta { font-size: var(--text-
|
|
267
|
+
.pl-node-meta { font-size: var(--text-sm); color: var(--muted); margin-top: 2px; text-align: center; max-width: 120px; overflow: hidden; text-overflow: ellipsis; }
|
|
268
268
|
.pl-node-loop { font-size: var(--text-sm); color: var(--muted); margin-top: 6px; display: flex; align-items: center; gap: 6px; }
|
|
269
269
|
.pipeline-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 12px 16px; margin-bottom: var(--space-4); cursor: pointer; transition: border-color var(--transition-fast), transform var(--transition-fast); }
|
|
270
270
|
.pipeline-card:hover { border-color: var(--blue); transform: translateY(-1px); }
|
|
@@ -358,6 +358,7 @@
|
|
|
358
358
|
.pr-title:hover { text-decoration: underline; }
|
|
359
359
|
.pr-desc { color: var(--muted); font-size: var(--text-sm); font-weight: 400; margin-top: 2px; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 400px; }
|
|
360
360
|
.pr-id { color: var(--muted); font-family: Consolas, monospace; font-size: var(--text-base); }
|
|
361
|
+
.pr-id-project { max-width: 120px; font-size: var(--text-xs); }
|
|
361
362
|
.pr-agent { font-size: var(--text-base); color: var(--text); }
|
|
362
363
|
.pr-branch { display: inline-block; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; font-family: Consolas, monospace; font-size: var(--text-sm); color: var(--muted); background: var(--bg); padding: var(--space-1) var(--space-3); border-radius: 3px; border: 1px solid var(--border); }
|
|
363
364
|
.pr-branch.branch-missing { color: var(--red); border-color: var(--red); background: rgba(248,81,73,0.12); }
|
|
@@ -377,7 +378,7 @@
|
|
|
377
378
|
.pr-badge.build-escalated { background: rgba(248,81,73,0.25); color: var(--red); border: 2px solid var(--red); font-weight: 600; }
|
|
378
379
|
/* W-mq08kuog001110a6 — Needs-Attention chip for stuck pending work items. */
|
|
379
380
|
.pr-badge.needs-attention { background: rgba(210,153,34,0.15); color: var(--yellow); border: 1px solid var(--yellow); font-weight: 600; cursor: help; }
|
|
380
|
-
.error-details-btn { font-size: var(--text-
|
|
381
|
+
.error-details-btn { font-size: var(--text-sm); padding: var(--space-1) var(--space-3); margin-left: var(--space-2); background: rgba(248,81,73,0.15); color: var(--red); border: 1px solid var(--red); border-radius: var(--radius-lg); cursor: pointer; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; }
|
|
381
382
|
.error-details-btn:hover { background: rgba(248,81,73,0.3); }
|
|
382
383
|
.pr-empty { color: var(--muted); font-style: italic; font-size: var(--text-md); padding: var(--space-6) 0; }
|
|
383
384
|
.pr-pager { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; }
|
|
@@ -670,7 +671,8 @@
|
|
|
670
671
|
in render-other.js, but with a dashed border + green text to read as
|
|
671
672
|
a placeholder/call-to-action. */
|
|
672
673
|
.empty-state-add {
|
|
673
|
-
display: inline-
|
|
674
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
675
|
+
min-height: 24px; min-width: 24px;
|
|
674
676
|
background: var(--surface2);
|
|
675
677
|
border: 1px dashed var(--border);
|
|
676
678
|
color: var(--green);
|
|
@@ -786,7 +788,15 @@
|
|
|
786
788
|
.cmd-chip.priority-medium { color: var(--yellow); border-color: rgba(210,153,34,0.3); background: rgba(210,153,34,0.08); }
|
|
787
789
|
.cmd-chip.priority-low { color: var(--muted); }
|
|
788
790
|
.cmd-chip.project-chip { color: var(--green); border-color: rgba(63,185,80,0.3); background: rgba(63,185,80,0.08); }
|
|
789
|
-
|
|
791
|
+
/* Close/remove affordance on command chips. Sized as a >=24x24px hit target
|
|
792
|
+
(W-mr3xgo9a a11y sweep) via inline-flex box + min dimensions rather than
|
|
793
|
+
relying on the bare glyph; the glyph stays centered. Negative vertical
|
|
794
|
+
margins keep the enlarged tap box from inflating the parent chip's height. */
|
|
795
|
+
.cmd-chip .chip-x {
|
|
796
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
797
|
+
min-width: 24px; min-height: 24px; margin: -4px -4px -4px 2px;
|
|
798
|
+
cursor: pointer; opacity: 0.5; font-size: var(--text-lg);
|
|
799
|
+
}
|
|
790
800
|
.cmd-chip .chip-x:hover { opacity: 1; }
|
|
791
801
|
.cmd-chip select {
|
|
792
802
|
background: transparent; border: none; color: inherit; font-size: var(--text-base);
|
|
@@ -978,7 +988,7 @@
|
|
|
978
988
|
.dispatch-agent { font-weight: 600; color: var(--text); }
|
|
979
989
|
.dispatch-task { flex: 1; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
980
990
|
.dispatch-time { font-size: var(--text-sm); color: var(--border); }
|
|
981
|
-
.dispatch-stuck { font-size: var(--text-
|
|
991
|
+
.dispatch-stuck { font-size: var(--text-sm); font-weight: 700; padding: var(--space-1) var(--space-3); border-radius: var(--radius-sm); text-transform: uppercase; letter-spacing: 0.3px; background: rgba(248,81,73,0.18); color: var(--red); border: 1px dashed var(--red); margin-left: var(--space-2); animation: pulse 1.5s infinite; cursor: help; }
|
|
982
992
|
|
|
983
993
|
/* Engine Log */
|
|
984
994
|
.log-list { max-height: 280px; overflow-y: auto; display: flex; flex-direction: column; gap: var(--space-1); }
|
package/dashboard.js
CHANGED
|
@@ -13767,11 +13767,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13767
13767
|
// routes the changes through an isolated worktree (so nothing is ever
|
|
13768
13768
|
// committed in the operator's live main). See engine/create-pr-worktree.js.
|
|
13769
13769
|
const checkoutMode = shared.resolveCheckoutMode(project);
|
|
13770
|
+
// Resolve the project's actual main/base branch instead of trusting
|
|
13771
|
+
// whatever `git rev-parse --abbrev-ref HEAD` returned as a safe base —
|
|
13772
|
+
// the live checkout may be sitting on a stale/topic branch whose
|
|
13773
|
+
// unrelated commits would otherwise leak into the new PR (W-mr3jbpru).
|
|
13774
|
+
const mainBranch = shared.resolveMainBranch(project.localPath, project.mainBranch);
|
|
13770
13775
|
const followups = hasChanges
|
|
13771
|
-
? prAction.buildCreatePrFollowups({ project: projectName, branch, contextOnly: !!body?.contextOnly, checkoutMode })
|
|
13776
|
+
? prAction.buildCreatePrFollowups({ project: projectName, branch, contextOnly: !!body?.contextOnly, checkoutMode, mainBranch })
|
|
13772
13777
|
: [];
|
|
13773
13778
|
recordCcTurnIfPresent(req, { kind: 'pr-action', id: '', title: `offer create-pr (${projectName})`, project: projectName, followups });
|
|
13774
|
-
return jsonReply(res, 200, { ok: true, hasChanges, branch, changedFileCount, checkoutMode, followups }, req);
|
|
13779
|
+
return jsonReply(res, 200, { ok: true, hasChanges, branch, changedFileCount, checkoutMode, mainBranch, followups }, req);
|
|
13775
13780
|
} catch (e) {
|
|
13776
13781
|
return jsonReply(res, e.statusCode || 400, { error: e.message }, req);
|
|
13777
13782
|
}
|
|
@@ -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
|
|
@@ -136,6 +136,21 @@ Live-mode agents run **in-place** in the operator's checkout, so when a dispatch
|
|
|
136
136
|
|
|
137
137
|
The core invariant holds end-to-end through restore: **the engine only ever switches branches — it never `git reset`s, `git clean`s, or `git stash`es the operator's tree, and never passes `--force`.** The one addition — the self-healing auto-commit — only ever runs `git add`/`git commit` on the engine's *own* agent branch, never on operator refs, and never discards.
|
|
138
138
|
|
|
139
|
+
## Create-PR chip safety (CC-initiated, separate from the dispatch path)
|
|
140
|
+
|
|
141
|
+
The Command Center **"Create PR"** chip (`dashboard.js` → `POST /api/pr-action/offer-create-pr` → `engine/pr-action.js#buildCreatePrFollowups`) is a **separate, ad-hoc path** that opens a PR for a local edit Command Center just made in a live-checkout project. It does **not** go through `prepareLiveCheckout`, so the dirty-tree / orphaned-branch / mid-operation defenses above (Guarantees 2 & 5, dispatch-start self-heal) do **not** apply to it. This path needs its own equivalent safety contract, because it shares the same failure mode: the live checkout may be sitting on a stale/topic branch left over from a previous run, a manually-checked-out branch, or any other non-main state.
|
|
142
|
+
|
|
143
|
+
**The bug it fixes (W-mr3jbpru).** The old live-mode chip message keyed its branch instruction off `git rev-parse --abbrev-ref HEAD` (the *current* branch) and only told the agent to branch off cleanly **if** that current branch already happened to be the project's main branch. If the checkout was on any other branch, the message told the agent to commit the new changes **directly onto that branch** — silently carrying all of that branch's unrelated commits into the new PR. There was no engine-side verification that the current branch was clean or at parity with main; it was trusted blindly. A user hit exactly this: a Create-PR chip PR ended up built on top of a leftover topic branch and carried unrelated diffs.
|
|
144
|
+
|
|
145
|
+
**The contract now (unconditional, current-branch never trusted as base).**
|
|
146
|
+
|
|
147
|
+
1. The handler resolves the project's **actual** main/base branch with `shared.resolveMainBranch(project.localPath, project.mainBranch)` — it does **not** trust `git rev-parse --abbrev-ref HEAD` as a safe base — and threads it to `buildCreatePrFollowups({ …, mainBranch })`.
|
|
148
|
+
2. The CC-facing message is **unconditional**: regardless of what is currently checked out, it instructs the agent to build the PR on a **fresh branch off the up-to-date `origin/<mainBranch>` tip**, via `git stash push --include-untracked` → `git fetch origin <mainBranch>` → `git checkout -B <new-well-named-branch> origin/<mainBranch>` → `git stash pop` → commit → push → open the PR against `<mainBranch>` → link via `/api/pull-requests/link` → **switch the working tree back to the original branch** it started on (the same restore step the old message had). The old `if <current-branch> is the project's default/main branch` conditional is gone entirely.
|
|
149
|
+
3. If `git stash pop` reports a merge conflict (the stashed diff doesn't cleanly apply onto the fresh main tip), the message instructs the agent to **stop and surface the conflict** to the user rather than force-resolving it silently.
|
|
150
|
+
4. The message **names the resolved main branch explicitly** (e.g. `origin/android`) instead of leaving "the project main branch" vague, so the always-branch-off-resolved-main rule is stated unambiguously.
|
|
151
|
+
|
|
152
|
+
This mirrors, in CC-instruction form, what `prepareLiveCheckout` enforces engine-side for the dispatch path: never reuse whatever branch the live checkout happened to be on as the base for new work. The **worktree-mode** branch of `buildCreatePrFollowups` is unaffected — that path already routes the changes through an isolated worktree (`engine/create-pr-worktree.js`) so nothing is ever committed on the operator's `main`.
|
|
153
|
+
|
|
139
154
|
## Operator workflow
|
|
140
155
|
|
|
141
156
|
### Enabling live mode (dashboard)
|
|
@@ -253,6 +268,7 @@ Live-checkout mode is deliberately small. These are NOT supported and will not b
|
|
|
253
268
|
| `engine.js` — worktree-pool / orphan-GC short-circuits | `worktreePath===null` no-ops in live mode (P-a3f9b206). |
|
|
254
269
|
| `engine/cleanup.js` — `runPeriodicWorktreeSweep` live filter | Excludes live-checkout projects from the registry-derived periodic worktree GC so the operator's primary checkout never enters the GC decision surface (PL-live-checkout-reliability-hardening). |
|
|
255
270
|
| `engine/create-pr-worktree.js` — `prepareCreatePrWorktree` step-4 restore | `reset --hard HEAD` (not the index-leaking `checkout -- .`) + retried untracked removal + `liveTreeDirty` surfaced + `shared.removeWorktree` teardown (PL-live-checkout-reliability-hardening). |
|
|
271
|
+
| `engine/pr-action.js` — `buildCreatePrFollowups({ …, mainBranch })` live-mode message | CC "Create PR" chip instruction. In live mode the PR is ALWAYS built on a fresh branch off `origin/<mainBranch>` (stash → fetch → `checkout -B` → pop → commit → push → PR → restore), never reusing the current branch as the base; stop-on-stash-pop-conflict. `dashboard.js` `POST /api/pr-action/offer-create-pr` resolves `mainBranch` via `shared.resolveMainBranch` and threads it in (W-mr3jbpru). |
|
|
256
272
|
| `dashboard/js/settings.js` — checkoutMode dropdown + chip + `set-liveCheckoutAutoReset` fleet toggle | Operator-facing UI; the fleet-wide auto-reset toggle persists to `engine.liveCheckoutAutoReset` (per-project UI deferred — config.json only) (P-a3f9b207; auto-reset toggle W-mqvejug6000eeb20). |
|
|
257
273
|
| `test/unit/{resolve-spawn-paths-live-mode,prepare-live-checkout,spawn-agent-live-mode-wiring,live-checkout-auto-stash}.test.js` | Wiring and contract tests (P-a3f9b208; auto-stash helpers W-mqtvnnj1000357fa). |
|
|
258
274
|
| `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` | Non-retryable refusal class. |
|
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 |
|
|
@@ -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/live-checkout.js
CHANGED
|
@@ -362,6 +362,23 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
362
362
|
// gitOpts.maxBuffer still wins (spread after the default).
|
|
363
363
|
const baseOpts = { cwd: localPath, maxBuffer: LIVE_CHECKOUT_GIT_MAX_BUFFER, ...(gitOpts || {}) };
|
|
364
364
|
|
|
365
|
+
// ── Step 0 (W-mr3tayu4): clear a stale .git/index.lock BEFORE any preflight
|
|
366
|
+
// git command runs. A leftover index.lock (from a crashed/killed git
|
|
367
|
+
// process) makes every subsequent git invocation fail with "Unable to
|
|
368
|
+
// create '<path>/.git/index.lock': File exists." In live-checkout mode the
|
|
369
|
+
// engine never git-reset/cleans the operator tree, so nothing else clears
|
|
370
|
+
// it — and the LIVE_CHECKOUT_FAILED classification is retryable, so every
|
|
371
|
+
// automatic retry hit the SAME stale lock and failed identically forever
|
|
372
|
+
// until an operator manually deleted it (observed incident: C:/office/src).
|
|
373
|
+
// Mirror the worktree-mode self-heal (engine.js `git worktree add` retry
|
|
374
|
+
// paths) by calling the SHARED age-gated (>300000ms) remover — see
|
|
375
|
+
// engine.js#removeStaleIndexLock, which now also delegates here. Because
|
|
376
|
+
// each retry re-enters spawnAgent → prepareLiveCheckout, running this at the
|
|
377
|
+
// top also clears a lock that appears mid-retry, not just at first preflight.
|
|
378
|
+
// The 5-min age gate is intentionally conservative so a lock held by a
|
|
379
|
+
// currently-running git process is never removed out from under it.
|
|
380
|
+
shared.removeStaleIndexLock(localPath, { log: (level, msg) => logFn(msg, level) });
|
|
381
|
+
|
|
365
382
|
// ── Step 1: git status --porcelain=v1 -b. Bail early on dirty tree. ─────
|
|
366
383
|
// Skipped when `skipDirtyCheck:true` (issue #522): GVFS/VFS-for-Git repos
|
|
367
384
|
// report all un-hydrated virtual files as modified — this is normal GVFS
|
package/engine/playbook.js
CHANGED
|
@@ -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
|
-
|
|
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/pr-action.js
CHANGED
|
@@ -154,14 +154,23 @@ const CREATE_PR_FOLLOWUP = Object.freeze({ kind: 'create-pr', label: 'Create PR'
|
|
|
154
154
|
* changes through an isolated git worktree so nothing is ever
|
|
155
155
|
* committed in the operator's live `main`. Defaults to `'live'`
|
|
156
156
|
* for back-compat when a caller doesn't resolve it.
|
|
157
|
+
* `mainBranch` — the project's resolved main/base branch
|
|
158
|
+
* (`shared.resolveMainBranch(project.localPath, project.mainBranch)`).
|
|
159
|
+
* In `'live'` mode the PR is ALWAYS built on a fresh branch off
|
|
160
|
+
* the up-to-date `origin/<mainBranch>` tip — never off whatever
|
|
161
|
+
* branch the live checkout happened to have checked out — so a
|
|
162
|
+
* stale/topic branch's unrelated commits can't leak into the new
|
|
163
|
+
* PR (W-mr3jbpru: "Create-PR reused stale topic branch as base").
|
|
164
|
+
* Falls back to `'main'` when a caller doesn't resolve it.
|
|
157
165
|
*
|
|
158
166
|
* Each chip: `{ kind:'create-pr', label, project, branch, message }` where
|
|
159
167
|
* `message` is the Command Center turn the chip click should send.
|
|
160
168
|
*/
|
|
161
|
-
function buildCreatePrFollowups({ project, branch, contextOnly = false, checkoutMode = 'live' } = {}) {
|
|
169
|
+
function buildCreatePrFollowups({ project, branch, contextOnly = false, checkoutMode = 'live', mainBranch = '' } = {}) {
|
|
162
170
|
const proj = (project && String(project).trim()) || '';
|
|
163
171
|
if (!proj) return [];
|
|
164
172
|
const br = branch && String(branch).trim() ? String(branch).trim() : '';
|
|
173
|
+
const mainRef = mainBranch && String(mainBranch).trim() ? String(mainBranch).trim() : 'main';
|
|
165
174
|
const linkBody = contextOnly
|
|
166
175
|
? `{"url":"<new PR url>","project":"${proj}","contextOnly":true}`
|
|
167
176
|
: `{"url":"<new PR url>","project":"${proj}","contextOnly":false}`;
|
|
@@ -185,21 +194,30 @@ function buildCreatePrFollowups({ project, branch, contextOnly = false, checkout
|
|
|
185
194
|
`(6) finally call POST /api/pr-action/cleanup-create-pr-worktree with {"project":"${proj}","worktreePath":"<worktreePath>"} to remove the worktree. ` +
|
|
186
195
|
`Never git-commit/-push in the live checkout. Then show me the PR URL.`;
|
|
187
196
|
} else {
|
|
188
|
-
// Live-checkout projects: operate in place, but
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
197
|
+
// Live-checkout projects: operate in place, but ALWAYS build the PR on a
|
|
198
|
+
// fresh branch off the up-to-date origin/<mainBranch> tip — never off
|
|
199
|
+
// whatever branch the live checkout happened to have checked out. Trusting
|
|
200
|
+
// the current branch let a stale/topic branch's unrelated commits leak into
|
|
201
|
+
// the new PR (W-mr3jbpru). The stash → fetch → checkout -B → pop sequence
|
|
202
|
+
// captures the intended changes and re-applies them on a clean base; the
|
|
203
|
+
// original branch is restored at the end so the PR branch is never left
|
|
204
|
+
// checked out in the shared operator tree.
|
|
193
205
|
const restoreClause = br
|
|
194
206
|
? `switch the working tree back to the ORIGINAL branch (${br})`
|
|
195
207
|
: 'switch the working tree back to the original branch it started on';
|
|
196
208
|
message =
|
|
197
209
|
`Create a PR from the local changes you just made in the ${proj} project. ` +
|
|
198
210
|
(br ? `Remember the working tree's current branch (${br}) as the ORIGINAL branch to return to when done. ` : '') +
|
|
199
|
-
`
|
|
200
|
-
`
|
|
201
|
-
`(
|
|
202
|
-
`(
|
|
211
|
+
`IMPORTANT: do NOT commit onto whatever branch is currently checked out — it may carry unrelated commits. ` +
|
|
212
|
+
`ALWAYS build the PR on a fresh branch off the up-to-date ${mainRef} tip so no stale/topic branch commits leak into the new PR. ` +
|
|
213
|
+
`Steps: (1) stash your intended uncommitted changes with \`git stash push --include-untracked\`; ` +
|
|
214
|
+
`(2) fetch the latest base with \`git fetch origin ${mainRef}\`; ` +
|
|
215
|
+
`(3) create a fresh well-named branch off the remote main tip with \`git checkout -B <new-well-named-branch> origin/${mainRef}\` (never branch off whatever was previously checked out); ` +
|
|
216
|
+
`(4) re-apply your changes with \`git stash pop\` — if this reports a merge conflict, STOP and surface the conflict to me rather than force-resolving it silently; ` +
|
|
217
|
+
`(5) stage and commit the changes with a clear conventional-commit message; ` +
|
|
218
|
+
`(6) push the new branch; (7) open a PR against ${mainRef} using the right CLI for the repo host (gh for GitHub, az repos for ADO); ` +
|
|
219
|
+
`(8) link it to the tracker by calling POST /api/pull-requests/link with ${linkBody} ${trackNote}; ` +
|
|
220
|
+
`(9) finally, ${restoreClause} — do not leave the new PR branch checked out. ` +
|
|
203
221
|
`Then show me the PR URL.`;
|
|
204
222
|
}
|
|
205
223
|
return [{ kind: CREATE_PR_FOLLOWUP.kind, label: CREATE_PR_FOLLOWUP.label, project: proj, branch: br || null, message }];
|
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'));
|
|
@@ -3372,6 +3403,15 @@ const ENGINE_DEFAULTS = {
|
|
|
3372
3403
|
// closed by default. Set to false to fall back to the legacy
|
|
3373
3404
|
// "only committed assets are visible" behavior.
|
|
3374
3405
|
harnessPropagateProjectLocal: true,
|
|
3406
|
+
// P-7c1a9e42 — cap on how many skill packs / command files
|
|
3407
|
+
// engine/discover-project-skills.js reads per surface (root `.claude/skills`,
|
|
3408
|
+
// each `<area>/.claude/skills`, etc.). Raised well above the module-level
|
|
3409
|
+
// DEFAULTS.maxFilesPerSurface fallback (50) so large monorepo sub-projects (e.g.
|
|
3410
|
+
// office/src/ocm/.claude/skills with ~80 packages) don't silently drop the
|
|
3411
|
+
// most-relevant skills past the cap. engine/playbook.js threads this into the
|
|
3412
|
+
// discovery `opts`; when no override is supplied the module falls back to its
|
|
3413
|
+
// own DEFAULTS.maxFilesPerSurface. See docs/project-skills.md.
|
|
3414
|
+
projectSkillMaxFilesPerSurface: 200,
|
|
3375
3415
|
// P-49e1c8b7 — hermetic harness opt-out. When TRUE the spawned agent runs
|
|
3376
3416
|
// with a known-empty harness surface around the worktree:
|
|
3377
3417
|
// - `--add-dir` is exactly `[minionsDir]` (user-scope skill/command/MCP
|
|
@@ -9374,6 +9414,8 @@ module.exports = {
|
|
|
9374
9414
|
execSilent,
|
|
9375
9415
|
shellSafeGh,
|
|
9376
9416
|
shellSafeGit,
|
|
9417
|
+
removeStaleIndexLock,
|
|
9418
|
+
STALE_INDEX_LOCK_MAX_AGE_MS,
|
|
9377
9419
|
shellSafeGitSync,
|
|
9378
9420
|
validateGitRef,
|
|
9379
9421
|
validateGhSlug,
|
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
|
-
|
|
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.
|
|
@@ -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
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.1.2322",
|
|
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"
|
package/prompts/cc-system.md
CHANGED
|
@@ -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
|
-
|
|
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**.
|
|
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`):
|