@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.
@@ -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` | 11px | Status badges, chip / tag labels |
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` (11px) | Chip / tag pill labels |
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: most recent created first, fall back to id.
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></td>' +
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-agent" title="' + escapeHtml(agentText) + '">' + escapeHtml(agentText) + '</span></td>' +
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)">&#x2197;</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:110px">' + // PR id (short label: `gh #481` / `ado #5383607`)
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:140px">' + // Agent
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>Agent</th><th>Branch</th><th>Review</th><th>Signed Off By</th><th>Build</th><th>Status</th><th>Observe</th><th>Created</th><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(11px * var(--minions-text-scale)); --text-base: calc(12px * var(--minions-text-scale));
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);
@@ -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(11px * var(--minions-text-scale)); --text-base: calc(12px * var(--minions-text-scale));
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); /* 11px — chip / tag pill labels */
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-xs); color: var(--muted); background: var(--surface2); padding: 1px 5px; border-radius: 8px; min-width: 16px; text-align: center; }
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-xs); color: var(--muted); margin-top: 2px; text-align: center; max-width: 120px; overflow: hidden; text-overflow: ellipsis; }
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-xs); 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
+ .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-block;
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
- .cmd-chip .chip-x { cursor: pointer; opacity: 0.5; margin-left: 2px; font-size: var(--text-lg); }
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-xs); 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; }
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. |
@@ -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 discoverProjectSkills(args) {
458
+ function discoverProjectSkillsWithDiagnostics(args) {
315
459
  const projectPath = args && args.projectPath;
316
- if (!projectPath || typeof projectPath !== 'string') return [];
317
- try { if (!fs.statSync(projectPath).isDirectory()) return []; } catch { return []; }
460
+ if (!projectPath || typeof projectPath !== 'string') return { entries: [], truncations: [] };
461
+ try { if (!fs.statSync(projectPath).isDirectory()) return { entries: [], truncations: [] }; } catch { return { entries: [], truncations: [] }; }
318
462
 
463
+ const scopeSubproject = _normalizeScopeSubproject(args.scopeSubproject);
319
464
  const opts = Object.assign({}, DEFAULTS, args.opts || {});
320
465
  const deadline = _now() + Math.max(1, opts.walltimeMs);
321
466
 
322
467
  const seenKey = new Set(); // dedupe across surfaces by `${kind}:${name}`
323
468
  const seenSlash = new Set();
324
469
  const all = [];
470
+ const truncations = [];
325
471
 
326
472
  // 1. Root-level skills.
327
- for (const entry of _discoverSkillsAt(projectPath, projectPath, opts, deadline, 'root')) {
473
+ for (const entry of _discoverSkillsAt(projectPath, projectPath, opts, deadline, 'root', truncations)) {
328
474
  const key = `skill:${entry.name}`;
329
475
  if (seenKey.has(key)) continue;
330
476
  seenKey.add(key);
@@ -332,7 +478,7 @@ function discoverProjectSkills(args) {
332
478
  }
333
479
 
334
480
  // 2. Root-level commands.
335
- for (const entry of _discoverCommandsAt(projectPath, projectPath, opts, deadline, 'root')) {
481
+ for (const entry of _discoverCommandsAt(projectPath, projectPath, opts, deadline, 'root', truncations)) {
336
482
  const key = `command:${entry.name}`;
337
483
  if (seenKey.has(key)) continue;
338
484
  seenKey.add(key);
@@ -348,7 +494,7 @@ function discoverProjectSkills(args) {
348
494
  for (const area of areas) {
349
495
  if (_now() > deadline) break;
350
496
  const areaBase = path.join(projectPath, area);
351
- for (const entry of _discoverSkillsAt(areaBase, projectPath, opts, deadline, `area:${area}`)) {
497
+ for (const entry of _discoverSkillsAt(areaBase, projectPath, opts, deadline, `area:${area}`, truncations)) {
352
498
  const key = `skill:${entry.name}`;
353
499
  if (seenKey.has(key)) continue;
354
500
  seenKey.add(key);
@@ -360,7 +506,7 @@ function discoverProjectSkills(args) {
360
506
  // half-scanned area: doing so drops commands while keeping skills from
361
507
  // the same area, causing test/prod divergence under load (PR-82 blind spot
362
508
  // regression, CI failure yemi33#28135287117).
363
- for (const entry of _discoverCommandsAt(areaBase, projectPath, opts, deadline, `area:${area}`)) {
509
+ for (const entry of _discoverCommandsAt(areaBase, projectPath, opts, deadline, `area:${area}`, truncations)) {
364
510
  const key = `command:${entry.name}`;
365
511
  if (seenKey.has(key)) continue;
366
512
  seenKey.add(key);
@@ -379,15 +525,39 @@ function discoverProjectSkills(args) {
379
525
  all.push(entry);
380
526
  }
381
527
 
382
- // Deterministic ordering: alphabetical by name, then by path as tiebreaker
383
- // so prompt rendering is stable across runs and OSes (readdir order varies).
528
+ // Deterministic ordering. When a scopeSubproject is set, entries under
529
+ // `<scopeSubproject>/` lead the list, then root-level, then other-sub-project entries;
530
+ // within each group the original name-then-path sort applies. When scopeSubproject
531
+ // is empty the group ranks are all equal so this reduces byte-for-byte to the
532
+ // historical global name-then-path sort (no regression for existing callers).
384
533
  all.sort((a, b) => {
534
+ if (scopeSubproject) {
535
+ const ga = _scopeGroup(a.path, scopeSubproject);
536
+ const gb = _scopeGroup(b.path, scopeSubproject);
537
+ if (ga !== gb) return ga - gb;
538
+ }
385
539
  if (a.name !== b.name) return a.name.localeCompare(b.name);
386
540
  return String(a.path || '').localeCompare(String(b.path || ''));
387
541
  });
388
542
 
389
543
  // Strip the internal _originLabel before returning (debugging-only).
390
- return all.map(({ _originLabel, ...rest }) => rest); // eslint-disable-line no-unused-vars
544
+ const entries = all.map(({ _originLabel, ...rest }) => rest); // eslint-disable-line no-unused-vars
545
+ return { entries, truncations };
546
+ }
547
+
548
+ /**
549
+ * @param {object} args
550
+ * @param {string} args.projectPath — absolute path to the project worktree / checkout
551
+ * @param {string} [args.scopeSubproject] — when set/non-empty, surface entries under
552
+ * `<scopeSubproject>/` first, then root-level, then other-sub-project entries (see
553
+ * discoverProjectSkillsWithDiagnostics). Unset/empty ⇒ global name-sort.
554
+ * @param {object} [args.opts] — override defaults (mostly for tests)
555
+ * @returns {Array<{kind:string,name:string,path:string,oneLineDescription:string,intents:string[]}>}
556
+ */
557
+ function discoverProjectSkills(args) {
558
+ // Delegates to the diagnostics variant and returns only the entries array so
559
+ // existing array-returning callers keep working unchanged.
560
+ return discoverProjectSkillsWithDiagnostics(args).entries;
391
561
  }
392
562
 
393
563
  /**
@@ -478,6 +648,7 @@ function renderReviewSkillsBlock(entries) {
478
648
 
479
649
  module.exports = {
480
650
  discoverProjectSkills,
651
+ discoverProjectSkillsWithDiagnostics,
481
652
  filterByIntents,
482
653
  renderProjectSkillsBlock,
483
654
  classifyIntents,
@@ -494,5 +665,9 @@ module.exports = {
494
665
  _parseExplicitIntents,
495
666
  _extractSlashCommandsFromDoc,
496
667
  _listAreas,
668
+ detectDominantSubproject,
669
+ _normalizeScopeSubproject,
670
+ _entrySubproject,
671
+ _scopeGroup,
497
672
  },
498
673
  };
@@ -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
@@ -386,6 +386,15 @@ const PLAYBOOK_OPTIONAL_VARS = new Set([
386
386
  // produce matches. Optional for the same reason as the review-only alias.
387
387
  'project_skills_block',
388
388
  'skip_project_skills',
389
+ // P-f52d81ba — monorepo-aware harness scoping. The engine derives a dominant
390
+ // sub-project for the dispatch (explicit meta.workdir first segment, or
391
+ // detectDominantSubproject over the current dispatch's changed paths) and threads
392
+ // it here so playbook.js can pass it as `scopeSubproject` into project-skill
393
+ // discovery. Optional with an empty-string default: single-root projects and
394
+ // mixed/root diffs legitimately resolve it to '' (flat discovery), and no
395
+ // playbook references {{dominant_subproject}} directly, so the unresolved-var check
396
+ // must stay quiet when it is unset.
397
+ 'dominant_subproject',
389
398
  // M006 — typed handoff envelope fields. Set on a follow-up dispatch meta
390
399
  // when lifecycle.js queues the item after a completing agent so the
391
400
  // receiving agent has explicit context about who handed off and why.
@@ -401,6 +410,7 @@ const PLAYBOOK_OPTIONAL_VARS = new Set([
401
410
  'prior_explore_context',
402
411
  ]);
403
412
 
413
+
404
414
  const PLAYBOOK_REQUIRED_VARS = {
405
415
  'implement': ['item_id', 'item_name', 'branch_name', 'project_path'],
406
416
  'implement-shared': ['item_id', 'item_name', 'branch_name', 'worktree_path'],
@@ -814,7 +824,27 @@ function renderPlaybook(type, vars) {
814
824
  const effectiveType = vars.parent_dispatch_type || type;
815
825
  const intents = intentsForPlaybook(effectiveType);
816
826
  if (intents.length > 0) {
817
- const entries = discover.discoverProjectSkills({ projectPath });
827
+ // P-f52d81ba monorepo-aware scoping. When the engine derived a
828
+ // dominant sub-project for this dispatch (git diff / PR files /
829
+ // references → detectDominantSubproject, threaded in via vars.dominant_subproject),
830
+ // surface that sub-project's skills FIRST. Empty/unset ⇒ today's flat
831
+ // global name-sort. Use the diagnostics variant so per-surface
832
+ // overflow (entries dropped past the maxFilesPerSurface cap) is
833
+ // observable instead of silent.
834
+ const scopeSubproject = typeof vars.dominant_subproject === 'string' ? vars.dominant_subproject : '';
835
+ const { entries, truncations } = discover.discoverProjectSkillsWithDiagnostics({
836
+ projectPath,
837
+ scopeSubproject,
838
+ opts: { maxFilesPerSurface: ENGINE_DEFAULTS.projectSkillMaxFilesPerSurface },
839
+ });
840
+ if (Array.isArray(truncations) && truncations.length > 0) {
841
+ log('warn', `discover-project-skills: ${truncations.length} surface(s) exceeded maxFilesPerSurface (entries dropped) for project ${matchedProject?.name || projectPath}${scopeSubproject ? ', subproject=' + scopeSubproject : ''}: ${truncations.map(t => `${t.dir} (${t.scanned}/${t.total})`).join('; ')}`);
842
+ }
843
+ // Stash discovery diagnostics on the vars object so the engine
844
+ // caller (spawnAgent) can record detected sub-project + truncations onto
845
+ // the dispatch record for later grounding/observability. Best-effort:
846
+ // callers that don't read it simply ignore the field.
847
+ vars._discoveryDiagnostics = { subproject: scopeSubproject, truncations: Array.isArray(truncations) ? truncations : [] };
818
848
  const filtered = discover.filterByIntents(entries, intents);
819
849
  // type === 'review' keeps PR-82 header copy + meta.review.* outcome
820
850
  // guidance; everything else uses the generic header that points at
@@ -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 branch off main and restore
189
- // the original branch so the PR branch is never left checked out.
190
- const branchClause = br
191
- ? `(the working tree is on branch ${br}; if ${br} is the project's default/main branch, create a new well-named branch off it and commit there rather than committing to ${br} directly)`
192
- : 'on a new well-named branch off the project main branch';
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
- `Steps: (1) in that project's working tree, stage and commit the modified files with a clear conventional-commit message ${branchClause}; ` +
200
- `(2) push the branch; (3) open a PR against the project's main branch using the right CLI for the repo host (gh for GitHub, az repos for ADO); ` +
201
- `(4) link it to the tracker by calling POST /api/pull-requests/link with ${linkBody} ${trackNote}; ` +
202
- `(5) finally, ${restoreClause} do not leave the new PR branch checked out. ` +
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
- const lockFile = path.join(rootDir, '.git', 'index.lock');
938
- try {
939
- if (fs.existsSync(lockFile)) {
940
- const age = Date.now() - fs.statSync(lockFile).mtimeMs;
941
- if (age > 300000) {
942
- fs.unlinkSync(lockFile);
943
- log('warn', `Removed stale index.lock (${Math.round(age / 1000)}s old) in ${rootDir}`);
944
- }
945
- }
946
- } catch (e) { log('warn', 'git: ' + e.message); }
942
+ return shared.removeStaleIndexLock(rootDir);
947
943
  }
948
944
 
949
945
  async function runWorktreeAdd(rootDir, worktreePath, addArgs, gitOpts, worktreeCreateRetries) {
@@ -1927,6 +1923,38 @@ async function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts
1927
1923
  }
1928
1924
  }
1929
1925
 
1926
+ // P-f52d81ba — persist the dispatch's detected monorepo sub-project (+ any per-surface
1927
+ // skill-discovery truncations the render surfaced) onto the dispatch record so
1928
+ // later grounding/observability can see which sub-project scoped discovery + harness
1929
+ // propagation. Best-effort: mutation failures must never block the spawn. Only
1930
+ // writes fields that carry a value so unset sub-projects leave the record untouched.
1931
+ function _recordDispatchDiscoveryDiagnostics(id, dispatchItem, subproject, diagnostics) {
1932
+ try {
1933
+ const dominantSubproject = (typeof subproject === 'string' && subproject) ? subproject : undefined;
1934
+ const truncations = diagnostics && Array.isArray(diagnostics.truncations) && diagnostics.truncations.length
1935
+ ? diagnostics.truncations
1936
+ : undefined;
1937
+ if (dominantSubproject === undefined && truncations === undefined) return;
1938
+ mutateDispatch((dispatch) => {
1939
+ for (const queue of ['pending', 'active', 'completed']) {
1940
+ const arr = Array.isArray(dispatch?.[queue]) ? dispatch[queue] : null;
1941
+ if (!arr) continue;
1942
+ const found = arr.find(d => d && d.id === id);
1943
+ if (!found) continue;
1944
+ if (dominantSubproject !== undefined) found._dominantSubproject = dominantSubproject;
1945
+ if (truncations !== undefined) found._skillDiscoveryTruncations = truncations;
1946
+ }
1947
+ return dispatch;
1948
+ });
1949
+ if (dispatchItem) {
1950
+ if (dominantSubproject !== undefined) dispatchItem._dominantSubproject = dominantSubproject;
1951
+ if (truncations !== undefined) dispatchItem._skillDiscoveryTruncations = truncations;
1952
+ }
1953
+ } catch (e) {
1954
+ log('warn', `record discovery diagnostics failed for ${id} (non-fatal): ${e.message}`);
1955
+ }
1956
+ }
1957
+
1930
1958
  // Seed a spawned agent's COPILOT_HOME mcp-config as a copy of the operator's
1931
1959
  // `~/.copilot/mcp-config.json` MINUS the servers in
1932
1960
  // `engine.copilotAgentDisabledMcpServers`, then point COPILOT_HOME at it.
@@ -3894,6 +3922,45 @@ async function spawnAgent(dispatchItem, config) {
3894
3922
 
3895
3923
  updateAgentStatus(id, AGENT_STATUS.READY, 'Worktree ready, preparing to spawn process');
3896
3924
 
3925
+ // ── P-f52d81ba — derive the effective monorepo sub-project for this dispatch ──────
3926
+ // Feeds BOTH project-skill discovery (scopeSubproject → sub-project's skills lead the
3927
+ // rendered project_skills_block) and project-local harness propagation
3928
+ // (workdir clip → only the sub-project's --project-harness-dir roots are surfaced).
3929
+ // Precedence:
3930
+ // 1. Explicit meta.workdir wins — effective sub-project = its first segment. No
3931
+ // diff work (the operator already declared the target sub-package).
3932
+ // 2. Else best-effort from the CURRENT dispatch's diff: `git diff
3933
+ // --name-only <main>...HEAD` in this worktree → detectDominantSubproject.
3934
+ // 3. Else '' (flat discovery / unclipped harness — today's behavior).
3935
+ // Contract: never blocks dispatch, never changes agent cwd (only an explicit
3936
+ // meta.workdir moves cwd, applied separately below), and degrades to '' on
3937
+ // any failure. Derived from THIS worktree's HEAD so a follow-up/parent's
3938
+ // stale diff can't leak in (AC: current-dispatch diff only).
3939
+ let _effectiveSubproject = '';
3940
+ try {
3941
+ if (validatedWorkdir) {
3942
+ _effectiveSubproject = String(validatedWorkdir).replace(/\\/g, '/').replace(/^\/+/, '').split('/')[0] || '';
3943
+ } else if (worktreePath && fs.existsSync(worktreePath) && project?.localPath) {
3944
+ let _subprojectChangedPaths = [];
3945
+ try {
3946
+ const _subprojectBase = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
3947
+ const _subprojectDiff = await execAsync(`git diff --name-only ${_subprojectBase}...HEAD`, { ..._gitOpts, cwd: worktreePath, timeout: 10000 });
3948
+ _subprojectChangedPaths = (_subprojectDiff.stdout || '').split('\n').map(s => s.trim()).filter(Boolean);
3949
+ } catch (e) {
3950
+ log('debug', `subproject-detect: git diff failed for ${id} (flat discovery): ${e.message}`);
3951
+ }
3952
+ if (_subprojectChangedPaths.length) {
3953
+ const { detectDominantSubproject } = require('./engine/discover-project-skills')._internal;
3954
+ const _det = detectDominantSubproject({ projectPath: project.localPath, changedPaths: _subprojectChangedPaths });
3955
+ if (_det && _det.confident && _det.subproject) _effectiveSubproject = _det.subproject;
3956
+ }
3957
+ }
3958
+ } catch (e) {
3959
+ log('warn', `subproject-detect failed for ${id} (non-fatal, flat discovery): ${e.message}`);
3960
+ _effectiveSubproject = '';
3961
+ }
3962
+
3963
+ let _refreshedDiscoveryDiagnostics = null;
3897
3964
  if (worktreePath && meta?.source === 'work-item' && meta?.item?.branchStrategy === 'shared-branch') {
3898
3965
  const refreshed = renderProjectWorkItemPromptForAgent(
3899
3966
  meta.item,
@@ -3903,15 +3970,21 @@ async function spawnAgent(dispatchItem, config) {
3903
3970
  project,
3904
3971
  rootDir,
3905
3972
  branchName,
3906
- { worktreePath }
3973
+ { worktreePath, dominantSubproject: _effectiveSubproject }
3907
3974
  );
3908
3975
  if (refreshed.prompt) {
3909
3976
  taskPrompt = refreshed.prompt;
3910
3977
  fullTaskPrompt = buildFullTaskPrompt(taskPrompt);
3911
3978
  safeWrite(promptPath, fullTaskPrompt);
3912
- log('info', `Refreshed shared-branch prompt for ${id} with worktree ${worktreePath}`);
3979
+ log('info', `Refreshed shared-branch prompt for ${id} with worktree ${worktreePath}${_effectiveSubproject ? ` (subproject=${_effectiveSubproject})` : ''}`);
3913
3980
  }
3981
+ _refreshedDiscoveryDiagnostics = refreshed.discoveryDiagnostics || null;
3914
3982
  }
3983
+ // Record the detected sub-project (+ any per-surface skill-discovery truncations the
3984
+ // shared-branch re-render surfaced) onto the dispatch record for later
3985
+ // grounding/observability. Sub-project-only for non-re-render dispatches (their
3986
+ // prompt was rendered at dispatch-build time; truncations are logged there).
3987
+ _recordDispatchDiscoveryDiagnostics(id, dispatchItem, _effectiveSubproject, _refreshedDiscoveryDiagnostics);
3915
3988
 
3916
3989
  // Inject dirty file list when worktree has uncommitted changes (e.g., max_turns retry)
3917
3990
  // This signals to the respawned agent that prior work exists in the worktree (#960)
@@ -4223,7 +4296,12 @@ async function spawnAgent(dispatchItem, config) {
4223
4296
  const filteredDirs = shared.filterProjectHarnessDirsForWorkdir(candidateDirs, {
4224
4297
  projectLocalPath: project.localPath,
4225
4298
  worktreePath,
4226
- workdir: validatedWorkdir,
4299
+ // P-f52d81ba — clip to the effective sub-project. Explicit meta.workdir wins
4300
+ // (validatedWorkdir, possibly multi-segment); otherwise use the
4301
+ // auto-derived dominant sub-project (single first-level segment) so an
4302
+ // ocm/-dominant diff surfaces only ocm/'s --project-harness-dir roots.
4303
+ // Empty ⇒ no clip (today's whole-project harness surface).
4304
+ workdir: validatedWorkdir || _effectiveSubproject || null,
4227
4305
  });
4228
4306
  for (const abs of filteredDirs) {
4229
4307
  if (!fs.existsSync(abs)) continue;
@@ -4231,7 +4309,7 @@ async function spawnAgent(dispatchItem, config) {
4231
4309
  propagatedProjectHarnessDirs.push(abs);
4232
4310
  }
4233
4311
  if (projectHarnessArgs.length) {
4234
- log('debug', `Project-local harness propagation: ${projectHarnessArgs.length / 2} dir(s) for ${id} (${worktreePath}${validatedWorkdir ? ', workdir=' + validatedWorkdir : ''})`);
4312
+ log('debug', `Project-local harness propagation: ${projectHarnessArgs.length / 2} dir(s) for ${id} (${worktreePath}${validatedWorkdir ? ', workdir=' + validatedWorkdir : (_effectiveSubproject ? ', subproject=' + _effectiveSubproject : '')})`);
4235
4313
  }
4236
4314
  } catch (err) {
4237
4315
  log('warn', `Project-local harness propagation failed for ${id} (non-fatal): ${err.message}`);
@@ -7699,6 +7777,30 @@ function _buildRunnerBriefVars(item, project) {
7699
7777
  }
7700
7778
  }
7701
7779
 
7780
+ // P-f52d81ba — sync effective-sub-project derivation for the build-time prompt render
7781
+ // (renderProjectWorkItemPromptForAgent runs synchronously, so no git diff).
7782
+ // Explicit meta.workdir wins (first segment). Otherwise best-effort from the
7783
+ // work item's references[*].path via detectDominantSubproject. Returns '' (flat
7784
+ // discovery) on anything unresolved. spawnAgent's async re-render overrides
7785
+ // this with a git-diff-derived sub-project via options.dominantSubproject.
7786
+ function _deriveDominantSubprojectSync(item, project) {
7787
+ try {
7788
+ const wd = item && item.meta && item.meta.workdir;
7789
+ if (typeof wd === 'string' && wd.trim()) {
7790
+ return wd.replace(/\\/g, '/').replace(/^\/+/, '').split('/')[0] || '';
7791
+ }
7792
+ const projectPath = project && project.localPath;
7793
+ if (!projectPath) return '';
7794
+ const paths = (item && Array.isArray(item.references))
7795
+ ? item.references.map(r => (r && typeof r.path === 'string') ? r.path : '').filter(Boolean)
7796
+ : [];
7797
+ if (paths.length === 0) return '';
7798
+ const { detectDominantSubproject } = require('./engine/discover-project-skills')._internal;
7799
+ const det = detectDominantSubproject({ projectPath, changedPaths: paths });
7800
+ return (det && det.confident && det.subproject) ? det.subproject : '';
7801
+ } catch { return ''; }
7802
+ }
7803
+
7702
7804
  /**
7703
7805
  * Scan work-items.json for manually queued tasks
7704
7806
  */
@@ -7873,6 +7975,14 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
7873
7975
  // Either flag suppresses both blocks on the dispatch.
7874
7976
  skip_project_review_skills: !!(item.meta && item.meta.skipProjectReviewSkills),
7875
7977
  skip_project_skills: !!(item.meta && (item.meta.skipProjectSkills || item.meta.skipProjectReviewSkills)),
7978
+ // P-f52d81ba — effective monorepo sub-project for scoped skill discovery. When the
7979
+ // async caller (spawnAgent) supplies a git-diff-derived sub-project it wins (even
7980
+ // ''); otherwise derive best-effort from explicit meta.workdir /
7981
+ // references[*].path. Empty ⇒ flat discovery. playbook.js reads this as the
7982
+ // discovery scopeSubproject.
7983
+ dominant_subproject: (typeof options.dominantSubproject === 'string')
7984
+ ? options.dominantSubproject
7985
+ : _deriveDominantSubprojectSync(item, project),
7876
7986
  // M006 — typed handoff envelope. Set on follow-up dispatch metas by
7877
7987
  // lifecycle.js when queuing an item after a completing agent. The
7878
7988
  // renderPlaybook path injects a "Handoff Context" section when truthy
@@ -7892,10 +8002,16 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
7892
8002
  if (playbookName === 'work-item' && workType === WORK_TYPE.REVIEW) {
7893
8003
  log('info', `Work item ${item.id} is type "review" but has no PR — using work-item playbook`);
7894
8004
  }
8005
+ const prompt = item.prompt || renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars) || item.description;
7895
8006
  return {
7896
8007
  needsReview: false,
7897
8008
  checkpointCount: cpResult.checkpointCount,
7898
- prompt: item.prompt || renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars) || item.description,
8009
+ prompt,
8010
+ // P-f52d81ba — surface the discovery diagnostics renderPlaybook stashed on
8011
+ // the vars object (detected sub-project + per-surface truncations) so spawnAgent
8012
+ // can record them onto the dispatch record. null when no playbook render
8013
+ // ran (item.prompt short-circuit) or discovery was skipped.
8014
+ discoveryDiagnostics: vars._discoveryDiagnostics || null,
7899
8015
  };
7900
8016
  }
7901
8017
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2320",
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"
@@ -89,16 +89,25 @@ If you start a small task and discover it's actually Medium (3+ files, more tool
89
89
 
90
90
  When genuinely in doubt about the size, delegate — agents have isolated worktrees, full tool access, durable work-item tracking, and no turn limits.
91
91
 
92
+ ### HARD STOP — never edit files yourself in a live/hybrid project
93
+ This overrides everything above, **including the direct-handling override**. For any project whose effective checkout mode is `live` (or `hybrid` for a code-authoring work-item type — `implement`/`fix`/`docs`/`decompose`), you MUST NOT use `Edit`/`Write`/`Bash` (or any tool) to mutate files inside that project's `localPath`, **regardless of task size** — not even a Small 1-line change, and not even when the human explicitly says to do it yourself. The Step-3 "do it yourself" allowance and the Step-1 direct-handling override do **not** apply to writes inside a live/hybrid checkout.
94
+
95
+ Why: live-mode projects have no worktree isolation — the engine runs agents in-place inside `localPath`, caps dispatch to one mutating operation at a time, and refuses to dispatch on a dirty tree (`live-checkout-dirty`, non-retryable). A stray CC edit leaves the checkout dirty in a way the dispatch bookkeeping does not track, and that dirty state then **blocks every subsequent live-mode dispatch** on the project until a human manually cleans it up. It is NOT covered by the engine's dispatch-time auto-stash/auto-reset recovery — those only run as part of an actual dispatch preflight, never for stray CC edits.
96
+
97
+ Instead, when a change is needed in a live/hybrid project, **dispatch a normal work item** (`POST /api/work-items`, `type: "implement"` or `"fix"` as usual) and let the engine own the live-checkout dispatch path (dirty-tree refusal, single-mutating-dispatch cap, auto-stash/auto-reset). Do not try to shortcut it with your own edit.
98
+
99
+ Read-only inspection is still fine to do yourself in a live/hybrid checkout: viewing files, `git status`/`git log`/`git diff`, and non-mutating build/test commands are all fair game. The restriction is specifically about **writes**. See "Project checkout modes (worktree / live / hybrid)" below for the mode contract this rule protects.
100
+
92
101
  ### After you make a local code edit yourself — offer to open a PR
93
- When you edit files **yourself** in a configured project's working tree (the Step-3 "do it yourself" path NOT a change a dispatched agent made in its own worktree), don't leave the diff sitting uncommitted. After you finish editing, call:
102
+ This flow is **worktree-mode only** it applies solely to projects whose effective checkout mode is `worktree`. For `live`/`hybrid` projects you never edit files yourself (see the HARD STOP above), so there is nothing to PR from a CC edit. If you ever find uncommitted changes sitting in a live-mode project's checkout anyway, do **not** commit or push them yourself — just report the dirty state to the user and let them decide how to clean it up.
103
+
104
+ When you edit files **yourself** in a configured worktree-mode project's working tree (the Step-3 "do it yourself" path — NOT a change a dispatched agent made in its own worktree), don't leave the diff sitting uncommitted. After you finish editing, call:
94
105
  ```
95
106
  curl -s -X POST http://localhost:{{dashboard_port}}/api/pr-action/offer-create-pr \
96
107
  -H 'Content-Type: application/json' -H 'X-CC-Turn-Id: {{cc_turn_id}}' \
97
108
  -d '{"project":"<project name>"}'
98
109
  ```
99
- This checks the project's working tree (`git status --porcelain`) and, when there are uncommitted changes, returns a **`[Create PR]`** follow-up chip to the user (same chip mechanism as the `pr-action` `[Comment]`/`[Fix once]`/`[Track for auto-fix]` chips). When the user clicks it, you'll receive a turn with **explicit step-by-step instructions — follow them exactly**. The instructions adapt to the project's checkout pattern, so just do what the turn says:
100
- - **Worktree-mode projects** (the default): the turn tells you to first `POST /api/pr-action/prepare-create-pr-worktree {"project":"…"}`. The server moves your uncommitted changes into an **isolated worktree** and restores the live checkout clean, returning `{worktreePath, branch, baseBranch}`. You then run **all git from inside `worktreePath`** (`git -C <worktreePath> …`) to commit/push/open the PR, then `POST /api/pr-action/cleanup-create-pr-worktree` to remove it. **Never commit, branch, or push in the live checkout** — that leaks commits onto the operator's `main`.
101
- - **Live-mode projects**: the turn tells you to commit on a new branch off main in the live checkout and switch back to the original branch when done.
110
+ This checks the project's working tree (`git status --porcelain`) and, when there are uncommitted changes, returns a **`[Create PR]`** follow-up chip to the user (same chip mechanism as the `pr-action` `[Comment]`/`[Fix once]`/`[Track for auto-fix]` chips). When the user clicks it, you'll receive a turn with **explicit step-by-step instructions — follow them exactly**. Because you only ever edit files yourself in worktree-mode projects (see the HARD STOP above), this is always the worktree-mode flow: the turn tells you to first `POST /api/pr-action/prepare-create-pr-worktree {"project":"…"}`. The server moves your uncommitted changes into an **isolated worktree** and restores the live checkout clean, returning `{worktreePath, branch, baseBranch}`. You then run **all git from inside `worktreePath`** (`git -C <worktreePath> …`) to commit/push/open the PR, then `POST /api/pr-action/cleanup-create-pr-worktree` to remove it. **Never commit, branch, or push in the live checkout** — that leaks commits onto the operator's `main`.
102
111
 
103
112
  Pass `"contextOnly":true` if the PR should be tracked-but-not-auto-reviewed; omit it to have the engine auto-manage the PR (review → fix → re-review → auto-merge). If `hasChanges` is `false`, there's nothing to PR — skip the offer. Don't commit/push on your own initiative; surface the chip and let the user decide.
104
113
 
@@ -327,6 +336,8 @@ Every configured project has an effective **checkout mode** — surfaced in your
327
336
  - **`live`** — agents run **in-place** inside the project's `localPath` (no worktree). The engine caps this to **one mutating dispatch at a time** per project and **refuses on a dirty tree**. Use only when worktrees are unworkable (e.g. Android `repo`, submodules, deep Windows paths, emulators that bind the real checkout).
328
337
  - **`hybrid`** — `live` **plus** a `liveValidation: { type, autoDispatch }` block. Coding work items (`implement`/`fix`/`docs`/`decompose`) author in **isolated worktrees** (full parallelism), while **only** work items whose type matches `liveValidation.type` (e.g. `build-and-test`) run **in-place on the live checkout**. This is the best of both: parallel code authoring + a real on-disk build/validation that can't run in a worktree. `type` accepts a single string (e.g. `"build-and-test"`) or an array of strings (e.g. `["implement","fix"]`) to route multiple work-item types to the live checkout at once.
329
338
 
339
+ **CC never writes into a live/hybrid checkout itself.** Because `live` (and the code-authoring surface of `hybrid`) runs in-place with no worktree isolation, a one-off CC edit inside such a project's `localPath` leaves the tree dirty and blocks every future live-mode dispatch (the engine refuses on a dirty tree, `live-checkout-dirty`, non-retryable — and it does not track or auto-recover stray CC edits). So for any needed change in a `live`/`hybrid` project you **dispatch a work item** and let the engine own the live-checkout path; you never `Edit`/`Write`/`Bash`-mutate files there yourself, regardless of task size or a direct-handling override. Read-only inspection remains fine. This is the same rule stated up top under "HARD STOP — never edit files yourself in a live/hybrid project"; the two sections agree by design.
340
+
330
341
  **When to recommend hybrid:** the project's build/test/validation genuinely cannot run in an isolated worktree (it needs the real checkout — submodules, a `repo`-managed tree, an emulator/dev-server bound to `localPath`, deep-path tooling), **but** you still want coding agents to work in parallel rather than serialize through the single live checkout. If the *whole* workflow must run on the real tree, use plain `live`. If nothing needs the real tree, stay on `worktree`.
331
342
 
332
343
  **Configuring it** (via the projects array on `POST /api/settings` — never hand-edit `config.json`):