@yemi33/minions 0.1.2382 → 0.1.2384

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.
Files changed (51) hide show
  1. package/bin/minions.js +1 -0
  2. package/dashboard/js/refresh.js +2 -1
  3. package/dashboard/js/settings.js +1 -1
  4. package/dashboard.js +37 -19
  5. package/docs/completion-reports.md +20 -1
  6. package/docs/keep-processes.md +7 -2
  7. package/docs/live-checkout-mode.md +7 -7
  8. package/docs/managed-spawn.md +14 -1
  9. package/docs/runtime-adapters.md +61 -26
  10. package/docs/skills.md +2 -0
  11. package/docs/workspace-manifests.md +1 -1
  12. package/docs/worktree-lifecycle.md +36 -0
  13. package/engine/acp-transport.js +273 -58
  14. package/engine/ado-comment.js +3 -1
  15. package/engine/agent-worker-pool.js +278 -54
  16. package/engine/cc-worker-pool.js +12 -3
  17. package/engine/cleanup.js +15 -11
  18. package/engine/cli.js +56 -28
  19. package/engine/comment-format.js +51 -14
  20. package/engine/create-pr-worktree.js +57 -79
  21. package/engine/dispatch.js +7 -2
  22. package/engine/execution-model.js +68 -0
  23. package/engine/gh-comment.js +6 -3
  24. package/engine/github.js +6 -7
  25. package/engine/keep-process-sweep.js +131 -9
  26. package/engine/lifecycle.js +126 -45
  27. package/engine/live-checkout.js +4 -2
  28. package/engine/llm.js +59 -83
  29. package/engine/managed-spawn.js +112 -5
  30. package/engine/memory-store.js +3 -1
  31. package/engine/playbook.js +4 -6
  32. package/engine/pooled-agent-process.js +512 -45
  33. package/engine/pr-action.js +6 -8
  34. package/engine/process-utils.js +154 -18
  35. package/engine/runtimes/claude.js +94 -25
  36. package/engine/runtimes/codex.js +1 -0
  37. package/engine/runtimes/contract.js +239 -0
  38. package/engine/runtimes/copilot.js +97 -9
  39. package/engine/runtimes/index.js +37 -9
  40. package/engine/shared.js +349 -82
  41. package/engine/spawn-agent.js +85 -116
  42. package/engine/spawn-phase-watchdog.js +8 -37
  43. package/engine/timeout.js +14 -10
  44. package/engine/worktree-gc.js +55 -46
  45. package/engine.js +418 -241
  46. package/package.json +1 -1
  47. package/playbooks/fix.md +20 -0
  48. package/playbooks/review.md +2 -0
  49. package/playbooks/shared-rules.md +2 -2
  50. package/prompts/cc-system.md +11 -11
  51. package/skills/check-self-authored-review-comment/SKILL.md +34 -0
package/engine/cli.js CHANGED
@@ -9,6 +9,7 @@ const shared = require('./shared');
9
9
  const { safeRead, safeJson, safeJsonArr, safeWrite, readWorkItems, readPullRequests, mutateControl, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT, FAILURE_CLASS } = shared;
10
10
  const queries = require('./queries');
11
11
  const agentWorkerPool = require('./agent-worker-pool');
12
+ const { resolveCommentExecutionModel } = require('./execution-model');
12
13
  const { getConfig, getControl, getDispatch, getAgentStatus,
13
14
  MINIONS_DIR, ENGINE_DIR, AGENTS_DIR, PLANS_DIR, PRD_DIR, CONTROL_PATH } = queries;
14
15
 
@@ -652,6 +653,12 @@ const commands = {
652
653
 
653
654
  const hadPid = agentPid && agentPid > 0; // track before liveness check
654
655
  if (agentPid && agentPid > 0 && !isPidAlive(agentPid)) agentPid = null;
656
+ if (agentPid && !shared.isDispatchExecutionPathSafe(item, config)) {
657
+ e.log('warn', `Reattach: refusing ${item.id} — persisted execution path is not safe under current checkout config`);
658
+ try { shared.killImmediate({ pid: agentPid }); }
659
+ catch (err) { e.log('warn', `Reattach: failed to terminate unsafe dispatch ${item.id}: ${err.message}`); }
660
+ agentPid = null;
661
+ }
655
662
 
656
663
  // PID was found but confirmed dead — exempt from restart grace period (#869)
657
664
  if (hadPid && !agentPid) {
@@ -811,18 +818,14 @@ const commands = {
811
818
  );
812
819
  } catch (err) { e.log('warn', `Orphan dispatch complete: ${err.message}`); }
813
820
 
814
- // P-d9e6b2c4 live-mode dispatch-end auto-restore + terminal-failure
815
- // notify on the engine-restart reattach completion path. originalRef
816
- // is only persisted for live-mode dispatches (P-c5a1f3b8), so its
817
- // presence is the liveMode signal here — the slimmed meta.project
818
- // carries no checkoutMode. Mirrors the in-process onAgentClose wiring
819
- // (engine.js). Fire-and-forget + best-effort: the helper swallows its
820
- // own errors and only ever issues a plain `git checkout <originalRef>`
821
- // (never --force/reset/clean/stash) against the operator tree.
822
- if (item.originalRef && item.meta?.branch && item.meta?.project?.localPath) {
821
+ // Restore only records explicitly dispatched live and still configured
822
+ // live. Worktree mode fails closed even if stale state carries
823
+ // originalRef fields.
824
+ if (shared.isLiveCheckoutDispatchRecord(item, config)) {
823
825
  try {
824
826
  require('./live-checkout').maybeRestoreLiveCheckoutFromRecord({
825
827
  item,
828
+ config,
826
829
  isTerminalFailure: !isSuccess,
827
830
  resultLabel: result,
828
831
  log: (lvl, msg) => e.log(lvl, msg),
@@ -1200,6 +1203,38 @@ const commands = {
1200
1203
  const timeout = config.engine?.shutdownTimeout || shared.ENGINE_DEFAULTS.shutdownTimeout;
1201
1204
  const deadline = Date.now() + timeout;
1202
1205
 
1206
+ async function cancelTimedOutPooled(ids) {
1207
+ const closing = [];
1208
+ for (const id of ids) {
1209
+ const procInfo = e.activeProcesses.get(id);
1210
+ if (!procInfo?._pooled) continue;
1211
+ try {
1212
+ procInfo.proc.kill();
1213
+ if (typeof procInfo.proc.waitForClose === 'function') {
1214
+ closing.push(procInfo.proc.waitForClose());
1215
+ }
1216
+ } catch (err) {
1217
+ e.log('warn', `Failed to cancel pooled dispatch ${id} during shutdown: ${err.message}`);
1218
+ }
1219
+ }
1220
+ await Promise.allSettled(closing);
1221
+
1222
+ const stillActive = new Set((getDispatch().active || []).map(item => item.id));
1223
+ for (const id of ids) {
1224
+ e.activeProcesses.delete(id);
1225
+ e.realActivityMap.delete(id);
1226
+ if (!stillActive.has(id)) continue;
1227
+ e.completeDispatch(
1228
+ id,
1229
+ DISPATCH_RESULT.ERROR,
1230
+ 'Pooled dispatch cancelled after graceful shutdown timeout',
1231
+ '',
1232
+ { failureClass: FAILURE_CLASS.TIMEOUT, retryable: true },
1233
+ );
1234
+ }
1235
+ finishShutdown(`Shutdown timeout (${timeout / 1000}s) — cancelled ${ids.length} pooled agent(s) for retry.`, 1);
1236
+ }
1237
+
1203
1238
  const poll = setInterval(() => {
1204
1239
  pooledIds = getOutstandingPooledIds();
1205
1240
  if (pooledIds.length === 0 && agentWorkerPool.isDrained()) {
@@ -1209,24 +1244,10 @@ const commands = {
1209
1244
  }
1210
1245
  if (Date.now() >= deadline) {
1211
1246
  clearInterval(poll);
1212
- for (const id of pooledIds) {
1213
- const procInfo = e.activeProcesses.get(id);
1214
- if (procInfo?._pooled) {
1215
- try { procInfo.proc.kill(); } catch (err) {
1216
- e.log('warn', `Failed to cancel pooled dispatch ${id} during shutdown: ${err.message}`);
1217
- }
1218
- e.activeProcesses.delete(id);
1219
- e.realActivityMap.delete(id);
1220
- }
1221
- e.completeDispatch(
1222
- id,
1223
- DISPATCH_RESULT.ERROR,
1224
- 'Pooled dispatch cancelled after graceful shutdown timeout',
1225
- '',
1226
- { failureClass: FAILURE_CLASS.TIMEOUT, retryable: true },
1227
- );
1228
- }
1229
- finishShutdown(`Shutdown timeout (${timeout / 1000}s) — cancelled ${pooledIds.length} pooled agent(s) for retry.`, 1);
1247
+ void cancelTimedOutPooled(pooledIds).catch((err) => {
1248
+ e.log('error', `Pooled shutdown cleanup failed: ${err.message}`);
1249
+ finishShutdown(`Shutdown cleanup failed after timeout: ${err.message}`, 1);
1250
+ });
1230
1251
  }
1231
1252
  }, 2000);
1232
1253
  }
@@ -2076,6 +2097,11 @@ const commands = {
2076
2097
  console.error('error: must supply either --body-file <path> or --body <text>');
2077
2098
  process.exit(2);
2078
2099
  }
2100
+ const executionModel = resolveCommentExecutionModel({
2101
+ dispatch: getDispatch(),
2102
+ agentId,
2103
+ workItemId,
2104
+ });
2079
2105
 
2080
2106
  // ── Azure DevOps: <prNumber> --host ado --ado-org --ado-project --repo-id ──
2081
2107
  if (isAdo) {
@@ -2097,6 +2123,7 @@ const commands = {
2097
2123
  const adoComment = require('./ado-comment');
2098
2124
  adoComment.postAdoPrComment({
2099
2125
  orgBase, project, repositoryId, prNumber, body, agentId, kind, workItemId,
2126
+ model: executionModel.model,
2100
2127
  resolved: flags.resolved === true,
2101
2128
  }).then((result) => {
2102
2129
  if (result && result.threadId) {
@@ -2135,7 +2162,7 @@ const commands = {
2135
2162
 
2136
2163
  try {
2137
2164
  const result = ghComment.postPrComment({
2138
- repo, prNumber, body, agentId, kind, workItemId,
2165
+ repo, prNumber, body, agentId, kind, workItemId, model: executionModel.model,
2139
2166
  });
2140
2167
  if (result.output) console.log(result.output);
2141
2168
  } catch (e) {
@@ -2274,6 +2301,7 @@ module.exports = {
2274
2301
  _readDispatchPid: readDispatchPid,
2275
2302
  _normalizeSessionBranch: normalizeSessionBranch,
2276
2303
  _dispatchSessionBranch: dispatchSessionBranch,
2304
+ _resolveCommentExecutionModel: resolveCommentExecutionModel, // exported for testing
2277
2305
  // W-mpcyvff6000pf828 (#2653) — heartbeat writer + factory exported for tests
2278
2306
  _writeHeartbeatNow: writeHeartbeatNow,
2279
2307
  _createHeartbeatInterval: createHeartbeatInterval,
@@ -48,14 +48,17 @@ function linkifyBrandTrailer(text) {
48
48
  // org/project/repoId).
49
49
  //
50
50
  // Marker format (single line, ASCII):
51
- // <!-- minions:agent=<agentId> kind=<kind> wi=<workItemId> -->
51
+ // <!-- minions:agent=<agentId> kind=<kind> wi=<workItemId> model=<modelId> -->
52
52
  //
53
53
  // Validation (enforced before the marker is built):
54
54
  // - agentId /^[a-z][a-z0-9-]{0,30}$/
55
55
  // - kind /^[a-z][a-z0-9-]{0,30}$/
56
56
  // - workItemId /^[A-Z]-[a-z0-9]+$/ (optional)
57
+ // - model concrete runtime execution model (optional)
57
58
  // - no field may contain `--` (would close the HTML comment early)
58
59
 
60
+ const { normalizeExecutionModel } = require('./execution-model');
61
+
59
62
  const AGENT_ID_RE = /^[a-z][a-z0-9-]{0,30}$/;
60
63
  const KIND_RE = /^[a-z][a-z0-9-]{0,30}$/;
61
64
  const WORK_ITEM_ID_RE = /^[A-Z]-[a-z0-9]+$/;
@@ -63,7 +66,7 @@ const WORK_ITEM_ID_RE = /^[A-Z]-[a-z0-9]+$/;
63
66
  // Marker line (single-line HTML comment). Multiline flag so it matches at the
64
67
  // start of any line — required for round-trip detection of the builder output.
65
68
  const MINIONS_COMMENT_MARKER_RE =
66
- /^<!--\s*minions:agent=([^\s]+)\s+kind=([^\s]+)(?:\s+wi=([^\s]+))?\s*-->/m;
69
+ /^<!--\s*minions:agent=([^\s]+)\s+kind=([^\s]+)(?:\s+wi=([^\s]+))?(?:\s+model=([^\s]+))?\s*-->/m;
67
70
 
68
71
  // Canonical-format sample marker for tests/fixtures that need a marked body but
69
72
  // don't care about the specific fields.
@@ -85,18 +88,44 @@ function _validateField(name, value, re) {
85
88
  }
86
89
  }
87
90
 
88
- function _validateMarkerInputs({ agentId, kind, workItemId }) {
91
+ function _validateMarkerInputs({ agentId, kind, workItemId, model }) {
89
92
  _validateField('agentId', agentId, AGENT_ID_RE);
90
93
  _validateField('kind', kind, KIND_RE);
91
94
  if (workItemId !== undefined && workItemId !== null) {
92
95
  _validateField('workItemId', workItemId, WORK_ITEM_ID_RE);
93
96
  }
97
+ if (model !== undefined && model !== null && !normalizeExecutionModel(model)) {
98
+ throw new Error(`invalid model: ${JSON.stringify(model)}`);
99
+ }
100
+ }
101
+
102
+ function _encodeMarkerModel(model) {
103
+ const encoded = encodeURIComponent(model);
104
+ return encoded.includes('--') ? encoded.replace(/-/g, '%2D') : encoded;
94
105
  }
95
106
 
96
- function _buildMarker({ agentId, kind, workItemId }) {
97
- _validateMarkerInputs({ agentId, kind, workItemId });
107
+ function _buildMarker({ agentId, kind, workItemId, model }) {
108
+ _validateMarkerInputs({ agentId, kind, workItemId, model });
98
109
  const wi = (workItemId !== undefined && workItemId !== null) ? ` wi=${workItemId}` : '';
99
- return `<!-- minions:agent=${agentId} kind=${kind}${wi} -->`;
110
+ const modelPart = model ? ` model=${_encodeMarkerModel(model)}` : '';
111
+ return `<!-- minions:agent=${agentId} kind=${kind}${wi}${modelPart} -->`;
112
+ }
113
+
114
+ const MODEL_SIGNOFF_RE =
115
+ /(\b(?:Review|Reviewed|Fixed|Verified|Rebased) by (?:Minions|\[Minions\]\([^)]+\)) \([^\r\n)]*? · )[^)\r\n]+(\))/g;
116
+
117
+ function stampExecutionModel(text, model) {
118
+ const normalized = normalizeExecutionModel(model);
119
+ if (!normalized || typeof text !== 'string') return text;
120
+ return text.replace(MODEL_SIGNOFF_RE, (_match, prefix, suffix) => `${prefix}${normalized}${suffix}`);
121
+ }
122
+
123
+ function _stampLeadingMarkerModel(text, model) {
124
+ if (!model || typeof text !== 'string') return text;
125
+ return text.replace(MINIONS_COMMENT_MARKER_RE, (marker, agentId, kind, workItemId) => {
126
+ if (marker !== text.slice(0, marker.length)) return marker;
127
+ return _buildMarker({ agentId, kind, workItemId, model });
128
+ });
100
129
  }
101
130
 
102
131
  /**
@@ -106,41 +135,49 @@ function _buildMarker({ agentId, kind, workItemId }) {
106
135
  * Idempotency: if `body` already starts with a minions marker it is returned
107
136
  * with the brand transform applied but no second marker prepended.
108
137
  *
109
- * @param {{agentId:string, kind:string, workItemId?:string, body?:string}} args
138
+ * @param {{agentId:string, kind:string, workItemId?:string, model?:string, body?:string}} args
110
139
  * @returns {string} the final comment body
111
140
  */
112
- function buildMinionsCommentBody({ agentId, kind, workItemId, body }) {
141
+ function buildMinionsCommentBody({ agentId, kind, workItemId, model, body }) {
113
142
  // Validate the inputs even when the body is pre-marked, so callers can't
114
143
  // silently bypass validation by pre-marking their body.
115
- _validateMarkerInputs({ agentId, kind, workItemId });
144
+ _validateMarkerInputs({ agentId, kind, workItemId, model });
116
145
  let safeBody = body == null ? '' : String(body);
117
146
 
147
+ safeBody = stampExecutionModel(safeBody, model);
148
+
118
149
  // Brand-link safety net: deterministically hyperlink the first bare
119
150
  // "… by Minions" signature trailer (no-op when the agent already linked it).
120
151
  safeBody = linkifyBrandTrailer(safeBody);
121
152
 
122
- if (_LEADING_MARKER_RE.test(safeBody)) return safeBody;
123
- const marker = _buildMarker({ agentId, kind, workItemId });
153
+ if (_LEADING_MARKER_RE.test(safeBody)) return _stampLeadingMarkerModel(safeBody, model);
154
+ const marker = _buildMarker({ agentId, kind, workItemId, model });
124
155
  return `${marker}\n\n${safeBody}`;
125
156
  }
126
157
 
127
158
  /**
128
- * Inverse of the marker builder: parse `{agentId, kind, workItemId}` out of a
129
- * marked body, or null when no marker is present.
159
+ * Inverse of the marker builder: parse `{agentId, kind, workItemId, model?}`
160
+ * out of a marked body, or null when no marker is present.
130
161
  */
131
162
  function parseMinionsMarker(body) {
132
163
  if (typeof body !== 'string' || body.length === 0) return null;
133
164
  const m = body.match(MINIONS_COMMENT_MARKER_RE);
134
165
  if (!m) return null;
135
- return {
166
+ const parsed = {
136
167
  agentId: m[1],
137
168
  kind: m[2],
138
169
  workItemId: m[3] === undefined ? undefined : m[3],
139
170
  };
171
+ if (m[4] !== undefined) {
172
+ try { parsed.model = decodeURIComponent(m[4]); }
173
+ catch { return null; }
174
+ }
175
+ return parsed;
140
176
  }
141
177
 
142
178
  module.exports = {
143
179
  linkifyBrandTrailer,
180
+ stampExecutionModel,
144
181
  MINIONS_BRAND_URL,
145
182
  // Neutral marker + body builder (consumed by gh-comment.js and ado-comment.js).
146
183
  buildMinionsCommentBody,
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * engine/create-pr-worktree.js — checkout-mode-aware Create-PR staging.
3
3
  *
4
- * Background (the bug this fixes): the CC "Create PR" flow has the LLM commit /
4
+ * Background (the bug this fixes): the CC "Create PR" flow used to have the LLM commit /
5
5
  * branch / push directly in the project's LIVE operator checkout
6
6
  * (`project.localPath`). For a `checkoutMode: "worktree"` project that is wrong —
7
7
  * a misstep by the LLM (committing before branching, freelancing `git pull`)
@@ -10,16 +10,13 @@
10
10
  * - `live` → operate in the live checkout (handled by the existing flow);
11
11
  * - `worktree` → operate in an isolated git worktree, never the live checkout.
12
12
  *
13
- * This module implements the `worktree` path. `prepareCreatePrWorktree` moves the
14
- * uncommitted edits CC made in the live checkout into a fresh worktree (created
15
- * off the same base commit, on a new branch) and then restores the live checkout
16
- * clean DETERMINISTICALLY, server-side, so nothing relies on the LLM running
17
- * git correctly. CC is then told to commit / push / open the PR inside the
18
- * returned worktree path, and to call `cleanupCreatePrWorktree` when done.
13
+ * This module implements the `worktree` path. `prepareCreatePrWorktree` copies
14
+ * pre-existing operator edits into a fresh worktree created from the configured
15
+ * main tip. The source checkout is never reset, cleaned, checked out, or deleted
16
+ * from; the operator remains the sole owner of that tree.
19
17
  *
20
- * Safety ordering: the worktree is populated and verified BEFORE the live
21
- * checkout is touched. If applying the diff fails, the worktree is removed and
22
- * the live checkout is left exactly as it was — the operator never loses work.
18
+ * If applying the diff fails, the worktree is removed and the source checkout is
19
+ * left exactly as it was.
23
20
  */
24
21
 
25
22
  const fs = require('fs');
@@ -29,16 +26,15 @@ const shared = require('./shared');
29
26
  const PATCH_APPLY_TIMEOUT_MS = 120000;
30
27
 
31
28
  /**
32
- * Move a worktree-mode project's uncommitted live-checkout changes into an
33
- * isolated worktree and restore the live checkout clean.
29
+ * Copy a worktree-mode project's uncommitted operator changes into an isolated
30
+ * worktree without modifying the source checkout.
34
31
  *
35
32
  * @returns {Promise<{ ok:true, worktreePath, branch, baseBranch, baseSha,
36
33
  * trackedChanged:boolean, untrackedCount:number } | { ok:false, reason:string }>}
37
- * @throws on a git/fs failure AFTER which the live checkout is guaranteed
38
- * untouched (the operator keeps their work).
34
+ * @throws on a git/fs failure; the source checkout remains untouched.
39
35
  */
40
36
  async function prepareCreatePrWorktree({
41
- project, branch, minionsDir, engine, log = () => {}, _git, _fs,
37
+ project, projects, branch, minionsDir, engine, log = () => {}, _git, _fs,
42
38
  } = {}) {
43
39
  const git = _git || ((args, opts) => shared.shellSafeGit(args, opts));
44
40
  const fsm = _fs || fs;
@@ -49,12 +45,15 @@ async function prepareCreatePrWorktree({
49
45
  if (!localPath) {
50
46
  throw new Error('prepareCreatePrWorktree: project.localPath required');
51
47
  }
48
+ if (shared.resolveCheckoutMode(project, shared.WORK_TYPE.IMPLEMENT) !== shared.CHECKOUT_MODES.WORKTREE) {
49
+ throw new Error('prepareCreatePrWorktree: project must use checkoutMode "worktree"');
50
+ }
52
51
  const mainBranch = (project.mainBranch && String(project.mainBranch).trim()) || 'main';
53
52
 
54
- // 1. Capture the uncommitted state from the live checkout.
55
- const baseSha = (await git(['-C', localPath, 'rev-parse', 'HEAD'])).trim();
56
- if (!/^[0-9a-f]{7,40}$/i.test(baseSha)) {
57
- throw new Error(`prepareCreatePrWorktree: could not resolve HEAD in ${localPath} (got ${JSON.stringify(baseSha)})`);
53
+ // 1. Capture the uncommitted state from the protected operator checkout.
54
+ const sourceHeadSha = (await git(['-C', localPath, 'rev-parse', 'HEAD'])).trim();
55
+ if (!/^[0-9a-f]{7,40}$/i.test(sourceHeadSha)) {
56
+ throw new Error(`prepareCreatePrWorktree: could not resolve HEAD in ${localPath} (got ${JSON.stringify(sourceHeadSha)})`);
58
57
  }
59
58
  // `git diff HEAD --binary` captures staged + unstaged tracked changes
60
59
  // (including deletions and binary deltas) as a single patch.
@@ -70,9 +69,29 @@ async function prepareCreatePrWorktree({
70
69
  return { ok: false, reason: 'no-changes' };
71
70
  }
72
71
 
73
- // 2. Create an isolated worktree off the SAME base commit the edits were made
74
- // against, on a NEW branch (so it never collides with `main` being checked
75
- // out in the live tree, and never commits onto a shared branch).
72
+ // 2. Resolve the configured main tip without changing the operator checkout.
73
+ // Prefer the fetched remote-tracking ref; local-only repos fall back to the
74
+ // local main branch.
75
+ try {
76
+ await git(['-C', localPath, 'fetch', 'origin', mainBranch], { timeout: 30000 });
77
+ } catch (e) {
78
+ log('warn', `[cc-create-pr] fetch origin ${mainBranch} failed; trying local ${mainBranch}: ${e.message}`);
79
+ }
80
+ let baseSha = '';
81
+ for (const ref of [`refs/remotes/origin/${mainBranch}`, `refs/heads/${mainBranch}`]) {
82
+ try {
83
+ baseSha = (await git(['-C', localPath, 'rev-parse', '--verify', ref])).trim();
84
+ } catch { /* try next base */ }
85
+ if (/^[0-9a-f]{7,40}$/i.test(baseSha)) break;
86
+ baseSha = '';
87
+ }
88
+ if (!baseSha) {
89
+ throw new Error(`prepareCreatePrWorktree: could not resolve origin/${mainBranch} or local ${mainBranch}`);
90
+ }
91
+
92
+ // 3. Create a fresh isolated branch from main, then apply only the captured
93
+ // uncommitted changes. Topic commits currently checked out by the operator
94
+ // never enter the PR.
76
95
  const uid = shared.uid();
77
96
  const sanitized = String(project.name || 'project').replace(/[^a-zA-Z0-9._-]/g, '-').slice(0, 40) || 'project';
78
97
  const requested = branch && String(branch).trim();
@@ -86,6 +105,7 @@ async function prepareCreatePrWorktree({
86
105
  const wtRel = (engine && engine.worktreeRoot) || shared.ENGINE_DEFAULTS.worktreeRoot;
87
106
  const worktreesBase = path.resolve(projectRoot, wtRel);
88
107
  const wtPath = path.join(worktreesBase, `cc-createpr-${sanitized}-${uid}`);
108
+ shared.assertWorktreeOutsideProjects(wtPath, Array.isArray(projects) ? projects : [project]);
89
109
  try { fsm.mkdirSync(worktreesBase, { recursive: true }); } catch { /* best-effort; worktree add will surface a real failure */ }
90
110
 
91
111
  await git(
@@ -94,8 +114,8 @@ async function prepareCreatePrWorktree({
94
114
  );
95
115
  try { shared.writeWorktreeOwnerMarker(wtPath, { source: 'cc-create-pr', project: project.name }); } catch { /* marker is best-effort */ }
96
116
 
97
- // 3. Reproduce the live-checkout changes inside the worktree. If anything here
98
- // fails, tear the worktree down and leave the live checkout untouched.
117
+ // 4. Reproduce the operator-checkout changes inside the worktree. If anything
118
+ // here fails, tear the worktree down and leave the source untouched.
99
119
  try {
100
120
  if (hasTracked) {
101
121
  const patchFile = path.join(wtPath, `.cc-createpr-${uid}.patch`);
@@ -111,64 +131,15 @@ async function prepareCreatePrWorktree({
111
131
  }
112
132
  } catch (e) {
113
133
  // Tear the worktree down via shared.removeWorktree (EPERM/EBUSY retry +
114
- // escalation + real-repo refusal — CLAUDE.md footgun #6). The live checkout
115
- // has NOT been touched yet at this point, so the operator keeps their work.
134
+ // escalation + real-repo refusal — CLAUDE.md footgun #6).
116
135
  try { shared.removeWorktree(wtPath, projectRoot, worktreesBase); } catch { /* leak rather than throw a second error */ }
117
136
  throw new Error(
118
137
  `prepareCreatePrWorktree: failed to stage changes into the worktree — ${e.message}. ` +
119
- 'The live checkout was left untouched.',
120
- );
121
- }
122
-
123
- // 4. The worktree now holds the changes — restore the live checkout clean.
124
- // The captured edits are SAFE in the worktree (step 3 verified), so the
125
- // documented job here is to return the live checkout to a clean HEAD.
126
- // Use `git reset --hard HEAD` (NOT `git checkout -- .`): the old
127
- // working-tree-only revert left the STAGED INDEX intact, so any change CC
128
- // had `git add`-ed stayed staged → the live checkout was reported "restored"
129
- // while still dirty, wedging the next Create-PR / live dispatch (the
130
- // operator's "no recovery from dirty checkout"). `reset --hard` reverts
131
- // staged + unstaged tracked changes in one shot; untracked entries are
132
- // removed surgically below (only the ones we captured — never ignored or
133
- // pre-existing untracked files, which `git clean` would wrongly nuke).
134
- // This `reset --hard` is intentional and scoped to THIS Create-PR-worktree
135
- // staging flow whose explicit contract is to restore the live checkout
136
- // clean — it is NOT the live-checkout DISPATCH mode (which never resets).
137
- const residualUntracked = [];
138
- try {
139
- await git(['-C', localPath, 'reset', '--hard', 'HEAD']);
140
- for (const rel of untracked) {
141
- const target = path.join(localPath, rel.replace(/\/$/, ''));
142
- try {
143
- // Retry transient Windows file locks (EBUSY/EPERM/ENOTEMPTY) before
144
- // giving up — a single failed unlink is what left the tree dirty. A few
145
- // quick retries ride out a transient AV/indexer lock without a long stall.
146
- shared._retryFsOp(() => fsm.rmSync(target, { recursive: true, force: true }), `cc-create-pr rm ${rel}`, { attempts: 4, baseMs: 100 });
147
- } catch (rmErr) {
148
- residualUntracked.push(rel);
149
- log('warn', `[cc-create-pr] could not remove untracked ${rel} from live checkout after retries: ${rmErr.message}`);
150
- }
151
- }
152
- } catch (e) {
153
- // shared.removeWorktree carries the EPERM/EBUSY retry + escalation +
154
- // real-repo refusal (CLAUDE.md footgun #6 — don't hand-roll force-remove).
155
- try { shared.removeWorktree(wtPath, projectRoot, worktreesBase); } catch { /* leak rather than double-throw */ }
156
- throw new Error(
157
- `prepareCreatePrWorktree: failed to restore live checkout — ${e.message}. ` +
158
- `Worktree at ${wtPath} may need manual cleanup.`,
138
+ 'The operator checkout was left untouched.',
159
139
  );
160
140
  }
161
141
 
162
- // If untracked removal could not fully clean the live tree, surface it in the
163
- // return (liveTreeDirty) instead of silently reporting success — the caller
164
- // can warn CC / the operator rather than letting the residue wedge the next
165
- // dispatch with a phantom "dirty" refusal.
166
- const liveTreeDirty = residualUntracked.length > 0;
167
- if (liveTreeDirty) {
168
- log('warn', `[cc-create-pr] live checkout ${localPath} left with ${residualUntracked.length} residual untracked path(s) after staging; surfacing liveTreeDirty`);
169
- } else {
170
- log('info', `[cc-create-pr] staged ${project.name} changes into isolated worktree ${wtPath} on branch ${branchName} (live checkout restored)`);
171
- }
142
+ log('info', `[cc-create-pr] copied ${project.name} changes into isolated worktree ${wtPath} on branch ${branchName}; operator checkout preserved`);
172
143
  return {
173
144
  ok: true,
174
145
  worktreePath: wtPath,
@@ -177,8 +148,7 @@ async function prepareCreatePrWorktree({
177
148
  baseSha,
178
149
  trackedChanged: hasTracked,
179
150
  untrackedCount: untracked.length,
180
- liveTreeDirty,
181
- residualUntracked,
151
+ sourceCheckoutPreserved: true,
182
152
  };
183
153
  }
184
154
 
@@ -187,7 +157,7 @@ async function prepareCreatePrWorktree({
187
157
  * touch a path that doesn't carry the minions ownership marker (so a bad/forged
188
158
  * path can never delete an arbitrary directory).
189
159
  */
190
- async function cleanupCreatePrWorktree({ worktreePath, project, _git, _fs } = {}) {
160
+ async function cleanupCreatePrWorktree({ worktreePath, project, projects, _git, _fs } = {}) {
191
161
  const git = _git || ((args, opts) => shared.shellSafeGit(args, opts));
192
162
  const fsm = _fs || fs;
193
163
  if (!worktreePath || typeof worktreePath !== 'string') {
@@ -200,6 +170,14 @@ async function cleanupCreatePrWorktree({ worktreePath, project, _git, _fs } = {}
200
170
  if (!shared.hasWorktreeOwnerMarker(worktreePath)) {
201
171
  return { ok: false, reason: 'not-owned' };
202
172
  }
173
+ try {
174
+ shared.assertWorktreeOutsideProjects(
175
+ worktreePath,
176
+ Array.isArray(projects) ? projects : (project ? [project] : shared.getProjects()),
177
+ );
178
+ } catch {
179
+ return { ok: false, reason: 'protected-project-overlap' };
180
+ }
203
181
  const fromDir = (project && project.localPath) || worktreePath;
204
182
  try {
205
183
  await git(['-C', fromDir, 'worktree', 'remove', '--force', worktreePath]);
@@ -978,7 +978,12 @@ function markCompletedSourceWorkItem(item) {
978
978
  // ─── Complete Dispatch ───────────────────────────────────────────────────────
979
979
 
980
980
  function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', resultSummary = '', opts = {}) {
981
- const { processWorkItemFailure = true, processWorkItemSuccess = false, failureClass } = opts;
981
+ const {
982
+ processWorkItemFailure = true,
983
+ processWorkItemSuccess = false,
984
+ countAgentRetryFailure = true,
985
+ failureClass,
986
+ } = opts;
982
987
  const agentRetryable = normalizeRetryableDecision(opts.agentRetryable ?? opts.retryable);
983
988
  let item = null;
984
989
  let completedSourceWorkItem = false;
@@ -1185,7 +1190,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
1185
1190
  // agent hits the threshold. Skip when no agent is resolvable
1186
1191
  // (anonymous failures shouldn't corrupt the map shape).
1187
1192
  const failedAgent = item.agent || wi.dispatched_to;
1188
- if (failedAgent) shared.bumpAgentRetryCount(wi, failedAgent);
1193
+ if (failedAgent && countAgentRetryFailure) shared.bumpAgentRetryCount(wi, failedAgent);
1189
1194
  delete wi.failReason;
1190
1195
  delete wi.failedAt;
1191
1196
  delete wi.dispatched_at;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Runtime-model evidence shared by dispatch, PR-comment, and review persistence.
3
+ *
4
+ * Priority is deliberate: runtime completion output is authoritative, an
5
+ * earlier stream/session capture is next, and the requested model is only a
6
+ * fallback when Minions explicitly sent one to the runtime. No catalog-based
7
+ * default inference belongs here.
8
+ */
9
+
10
+ const UNKNOWN_MODEL = 'unknown';
11
+ const MODEL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
12
+
13
+ function normalizeExecutionModel(value) {
14
+ if (typeof value !== 'string') return null;
15
+ const model = value.trim();
16
+ return MODEL_ID_RE.test(model) ? model : null;
17
+ }
18
+
19
+ function resolveExecutionModel({
20
+ reportedModel,
21
+ capturedModel,
22
+ requestedModel,
23
+ } = {}) {
24
+ for (const [source, value] of [
25
+ ['reported', reportedModel],
26
+ ['captured', capturedModel],
27
+ ['requested', requestedModel],
28
+ ]) {
29
+ const model = normalizeExecutionModel(value);
30
+ if (model) return { model, source };
31
+ }
32
+ return { model: UNKNOWN_MODEL, source: 'unknown' };
33
+ }
34
+
35
+ function _dispatchWorkItemId(item) {
36
+ return item?.meta?.item?.id
37
+ || item?.meta?.workItemId
38
+ || item?.workItemId
39
+ || null;
40
+ }
41
+
42
+ function resolveCommentExecutionModel({
43
+ dispatch,
44
+ agentId,
45
+ workItemId,
46
+ } = {}) {
47
+ const active = Array.isArray(dispatch?.active) ? dispatch.active : [];
48
+ const candidates = active.filter((item) => {
49
+ if (String(item?.agent || '').toLowerCase() !== String(agentId || '').toLowerCase()) return false;
50
+ if (!workItemId) return true;
51
+ return item.id === workItemId || _dispatchWorkItemId(item) === workItemId;
52
+ });
53
+ candidates.sort((a, b) => String(b.started_at || '').localeCompare(String(a.started_at || '')));
54
+ const item = candidates[0] || null;
55
+ if (!item) return { model: null, source: 'none' };
56
+ return resolveExecutionModel({
57
+ capturedModel: item?.executionModel,
58
+ requestedModel: item?.requestedModel,
59
+ });
60
+ }
61
+
62
+ module.exports = {
63
+ UNKNOWN_MODEL,
64
+ MODEL_ID_RE,
65
+ normalizeExecutionModel,
66
+ resolveExecutionModel,
67
+ resolveCommentExecutionModel,
68
+ };
@@ -4,7 +4,7 @@
4
4
  * minions-authored PR comments by structure rather than by body shape.
5
5
  *
6
6
  * Marker format (single line, ASCII):
7
- * <!-- minions:agent=<agentId> kind=<kind> wi=<workItemId> -->
7
+ * <!-- minions:agent=<agentId> kind=<kind> wi=<workItemId> model=<modelId> -->
8
8
  *
9
9
  * Followed by `\n\n` and then the caller-provided body. `wi=` is omitted when
10
10
  * no workItemId is supplied. The marker is intentionally minimal so it round-
@@ -14,6 +14,7 @@
14
14
  * - agentId /^[a-z][a-z0-9-]{0,30}$/ (lowercase, hyphenated, ≤31 chars)
15
15
  * - kind /^[a-z][a-z0-9-]{0,30}$/ (same shape — categorical tag)
16
16
  * - workItemId /^[A-Z]-[a-z0-9]+$/ (e.g. W-mp3bp0ha000997ab, P-d5a8c9b6)
17
+ * - model concrete runtime model, when active-dispatch evidence exists
17
18
  * - no field may contain `--` (would close the HTML comment early)
18
19
  * - no field may contain `=` `<` `>` `\n` `"` `'` `` ` `` ` ` (whitespace)
19
20
  *
@@ -153,13 +154,14 @@ function postPrComment({
153
154
  agentId,
154
155
  kind,
155
156
  workItemId,
157
+ model,
156
158
  timeoutMs = 30000,
157
159
  execFileSync = _execFileSync,
158
160
  resolveTokenForSlug,
159
161
  } = {}) {
160
162
  _validateRepo(repo);
161
163
  _validatePrNumber(prNumber);
162
- const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body });
164
+ const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, model, body });
163
165
  const file = _writeTempBodyFile(finalBody);
164
166
  const env = _resolveTokenEnvForRepo(repo, resolveTokenForSlug);
165
167
  try {
@@ -182,13 +184,14 @@ function postPrReviewComment({
182
184
  agentId,
183
185
  kind,
184
186
  workItemId,
187
+ model,
185
188
  timeoutMs = 30000,
186
189
  execFileSync = _execFileSync,
187
190
  resolveTokenForSlug,
188
191
  } = {}) {
189
192
  _validateRepo(repo);
190
193
  _validatePrNumber(prNumber);
191
- const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body });
194
+ const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, model, body });
192
195
  const file = _writeTempBodyFile(finalBody);
193
196
  const env = _resolveTokenEnvForRepo(repo, resolveTokenForSlug);
194
197
  try {