@yemi33/minions 0.1.2319 → 0.1.2321

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dashboard.js CHANGED
@@ -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
  }
@@ -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. |
@@ -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 }];
@@ -1098,6 +1098,28 @@ function createStreamConsumer(ctx) {
1098
1098
  // form `...prev.next...`. First push is NEVER prefixed (would leave every
1099
1099
  // reply with a leading blank line). Reset by reset() on stream restart.
1100
1100
  let _hadPriorAssistantMessage = false;
1101
+ // `terminalText` accumulates EVERY non-tool assistant.message across the
1102
+ // whole turn, including ones separated by intervening tool calls. That
1103
+ // whole-turn scope is wrong for `ctx.setText(...)` on the `result` event:
1104
+ // the dashboard's `ccSegmentsFinalize`/`_ccSegMergeText` (render-utils.js)
1105
+ // expect the authoritative final text to describe ONLY the *trailing* text
1106
+ // segment (correct for Claude's single last-message `result.result`), and
1107
+ // merge it into just the last segment. Sending the whole-turn text there
1108
+ // duplicated earlier paragraphs (W-mr3rdn9j000u6d5d) because the trailing
1109
+ // segment only ever received the post-tool delta via ctx.pushText, not the
1110
+ // full accumulated turn.
1111
+ //
1112
+ // `trailingText`/`_hadPriorTrailingMessage` mirror `terminalText`/
1113
+ // `_hadPriorAssistantMessage` but are reset to '' / false on every tool
1114
+ // boundary (a real tool call — not `task_complete`), so their scope always
1115
+ // matches the trailing segment the dashboard will merge into.
1116
+ let trailingText = '';
1117
+ let _hadPriorTrailingMessage = false;
1118
+
1119
+ function _resetTrailingSegment() {
1120
+ trailingText = '';
1121
+ _hadPriorTrailingMessage = false;
1122
+ }
1101
1123
 
1102
1124
  function _captureTaskComplete(summary, success = true, { clearBuffer = false } = {}) {
1103
1125
  if (typeof summary !== 'string' || !summary) return;
@@ -1117,7 +1139,7 @@ function createStreamConsumer(ctx) {
1117
1139
  // The result event is the first Copilot event that contains the resumable
1118
1140
  // sessionId. Do not mark the earlier assistant.message as terminal or
1119
1141
  // Minions can resolve before session persistence data is available.
1120
- const finalText = terminalText || copilotMessageBuffer || taskCompleteSummary;
1142
+ const finalText = trailingText || copilotMessageBuffer || taskCompleteSummary;
1121
1143
  if (finalText) ctx.setText(finalText);
1122
1144
  }
1123
1145
 
@@ -1149,21 +1171,30 @@ function createStreamConsumer(ctx) {
1149
1171
  // the terminal answer. A non-tool assistant.message overrides any
1150
1172
  // streamed deltas (Copilot's authoritative final text for the turn).
1151
1173
  if (content && !hasTools) {
1152
- // Second-and-later terminating message in a single turn: prefix the
1153
- // pushed text with `\n\n` so the dashboard merge sees a clean
1154
- // paragraph boundary instead of welding `<prev>.<next>` together
1155
- // (W-mq1jwwqo000f20d9 CC chunk welding fix). The first push must
1156
- // NOT be prefixed or every reply starts with a blank line. Tool-use
1157
- // boundaries are skipped those get rendered as visible chips
1158
- // client-side and already act as natural separators.
1159
- const pushed = _hadPriorAssistantMessage ? '\n\n' + content : content;
1174
+ // Second-and-later terminating message within the SAME trailing
1175
+ // segment (no tool boundary since): prefix the pushed text with
1176
+ // `\n\n` so the dashboard merge sees a clean paragraph boundary
1177
+ // instead of welding `<prev>.<next>` together (W-mq1jwwqo000f20d9
1178
+ // CC chunk welding fix). The first push in a segment must NOT be
1179
+ // prefixed (would leave every reply/segment with a leading blank
1180
+ // line). This is now gated on `_hadPriorTrailingMessage` (reset on
1181
+ // every real tool boundary below), not the whole-turn flag — a
1182
+ // tool boundary already opens a brand-new segment on the dashboard
1183
+ // side (ccSegmentsApplyChunk), so the first post-tool push needs no
1184
+ // extra separator, and `trailingText` must track exactly what was
1185
+ // pushed so ctx.setText(...) on `result` reconciles cleanly
1186
+ // (W-mr3rdn9j000u6d5d).
1187
+ const pushed = _hadPriorTrailingMessage ? '\n\n' + content : content;
1160
1188
  terminalText = _hadPriorAssistantMessage ? terminalText + '\n\n' + content : content;
1189
+ trailingText = _hadPriorTrailingMessage ? trailingText + '\n\n' + content : content;
1161
1190
  ctx.pushText(pushed);
1162
1191
  _hadPriorAssistantMessage = true;
1192
+ _hadPriorTrailingMessage = true;
1163
1193
  }
1164
1194
  copilotMessageBuffer = '';
1165
1195
  }
1166
1196
  if (Array.isArray(data.toolRequests)) {
1197
+ let sawRealTool = false;
1167
1198
  for (const tr of data.toolRequests) {
1168
1199
  if (!tr || !tr.name) continue;
1169
1200
  if (tr.name === 'task_complete') {
@@ -1171,7 +1202,12 @@ function createStreamConsumer(ctx) {
1171
1202
  continue;
1172
1203
  }
1173
1204
  ctx.pushToolUse(tr.name, tr.arguments || {});
1205
+ sawRealTool = true;
1174
1206
  }
1207
+ // A real (non-task_complete) tool call closes the trailing segment —
1208
+ // the NEXT non-tool assistant.message starts a fresh segment on the
1209
+ // dashboard side, so its finalize-time scope must reset here too.
1210
+ if (sawRealTool) _resetTrailingSegment();
1175
1211
  }
1176
1212
  return;
1177
1213
  }
@@ -1188,6 +1224,11 @@ function createStreamConsumer(ctx) {
1188
1224
  if (!ctx.toolUseAlreadySeen(name, input)) {
1189
1225
  ctx.pushToolUse(name, input);
1190
1226
  }
1227
+ // Same trailing-segment boundary reset as the toolRequests branch above
1228
+ // — `tool.execution_start` is the standalone event shape observed in
1229
+ // the primary repro (assistant.message → tool.execution_start →
1230
+ // assistant.message → result).
1231
+ _resetTrailingSegment();
1191
1232
  }
1192
1233
  }
1193
1234
 
@@ -1196,6 +1237,7 @@ function createStreamConsumer(ctx) {
1196
1237
  terminalText = '';
1197
1238
  taskCompleteSummary = '';
1198
1239
  _hadPriorAssistantMessage = false;
1240
+ _resetTrailingSegment();
1199
1241
  }
1200
1242
 
1201
1243
  return { consume, reset };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2319",
3
+ "version": "0.1.2321",
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"