@yemi33/minions 0.1.2251 → 0.1.2253

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
@@ -53,6 +53,7 @@ const steering = require('./engine/steering');
53
53
  const steeringStore = require('./engine/steering-store');
54
54
  const projectDiscovery = require('./engine/project-discovery');
55
55
  const prAction = require('./engine/pr-action');
56
+ const createPrWorktree = require('./engine/create-pr-worktree');
56
57
  const prResolve = require('./engine/pr-resolve');
57
58
  const prFixTarget = require('./engine/pr-fix-target');
58
59
  const prTrack = require('./engine/pr-track');
@@ -299,6 +300,51 @@ try {
299
300
  });
300
301
  } catch { /* defensive — wiring is best-effort, queries probes still work without it */ }
301
302
 
303
+ // opg-microsoft/minions#381 — watch each project's .git/HEAD with fs.watch so
304
+ // a bare `git checkout` / `git switch` outside the dashboard immediately busts
305
+ // the status cache rather than waiting for the next 4-second SPA poll to detect
306
+ // the mtime change. When HEAD changes the watcher calls invalidateStatusCache()
307
+ // which (a) clears _statusCache and (b) debounce-pushes the updated state to
308
+ // connected SSE clients within ~500ms — zero polling lag for SSE consumers.
309
+ //
310
+ // The mtime-based polling in getStatusSlowStateMtimePaths + _projectGitRefFiles
311
+ // (queries.js) remains as a belt-and-suspenders fallback for environments where
312
+ // fs.watch is unreliable (network drives, some WSL mounts).
313
+ const _projectHeadWatchers = new Map(); // localPath → fs.FSWatcher
314
+
315
+ function _setupProjectHeadWatchers() {
316
+ const currentPaths = new Set(PROJECTS.filter(p => p && p.localPath).map(p => p.localPath));
317
+ // Close watchers for projects that are no longer configured.
318
+ for (const [localPath, watcher] of _projectHeadWatchers) {
319
+ if (!currentPaths.has(localPath)) {
320
+ try { watcher.close(); } catch { /* ignore close errors */ }
321
+ _projectHeadWatchers.delete(localPath);
322
+ }
323
+ }
324
+ // Add watchers for newly-configured projects.
325
+ for (const project of PROJECTS) {
326
+ if (!project || !project.localPath || _projectHeadWatchers.has(project.localPath)) continue;
327
+ let headPath = null;
328
+ try {
329
+ const resolvedGitDir = queries._resolveGitDir(project.localPath);
330
+ if (resolvedGitDir) headPath = path.join(resolvedGitDir, 'HEAD');
331
+ } catch { /* _resolveGitDir is best-effort */ }
332
+ if (!headPath) continue;
333
+ try {
334
+ const watcher = fs.watch(headPath, () => {
335
+ try { invalidateStatusCache(); } catch { /* best-effort */ }
336
+ });
337
+ watcher.on('error', () => {
338
+ // Silently discard watch errors (e.g. path deleted) — polling fallback handles it.
339
+ try { watcher.close(); } catch { /* ignore */ }
340
+ _projectHeadWatchers.delete(project.localPath);
341
+ });
342
+ _projectHeadWatchers.set(project.localPath, watcher);
343
+ } catch { /* fs.watch not available on this fs (network drive etc.) — polling covers it */ }
344
+ }
345
+ }
346
+ try { _setupProjectHeadWatchers(); } catch { /* best-effort */ }
347
+
302
348
  function resolveScheduleProjectValue(project, projects = PROJECTS) {
303
349
  if (project === undefined) return { project: undefined };
304
350
  const target = shared.resolveConfiguredProject(project, projects);
@@ -9042,6 +9088,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9042
9088
 
9043
9089
  reloadConfig(); // Update in-memory project list immediately
9044
9090
  warmProjectGitStatusCache(); // Probe the new project's git status in the background
9091
+ try { _setupProjectHeadWatchers(); } catch { /* best-effort */ } // opg#381: watch new project's HEAD
9045
9092
  // includeSlow: PROJECTS lives in the slow-state cache (60s TTL); without
9046
9093
  // flushing it, /api/status keeps returning the previous project list for
9047
9094
  // up to a minute after the add. Matches handleProjectsRemove's behavior.
@@ -13279,11 +13326,53 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13279
13326
  const changedFileCount = porcelain.split('\n').map(s => s.trim()).filter(Boolean).length;
13280
13327
  const hasChanges = changedFileCount > 0;
13281
13328
  const branch = (typeof body?.branch === 'string' && body.branch.trim()) || currentBranch || '';
13329
+ // The Create-PR action follows the SAME checkout pattern as a dispatch
13330
+ // for this project: 'live' commits in the live checkout; 'worktree'
13331
+ // routes the changes through an isolated worktree (so nothing is ever
13332
+ // committed in the operator's live main). See engine/create-pr-worktree.js.
13333
+ const checkoutMode = shared.resolveCheckoutMode(project);
13282
13334
  const followups = hasChanges
13283
- ? prAction.buildCreatePrFollowups({ project: projectName, branch, contextOnly: !!body?.contextOnly })
13335
+ ? prAction.buildCreatePrFollowups({ project: projectName, branch, contextOnly: !!body?.contextOnly, checkoutMode })
13284
13336
  : [];
13285
13337
  recordCcTurnIfPresent(req, { kind: 'pr-action', id: '', title: `offer create-pr (${projectName})`, project: projectName, followups });
13286
- return jsonReply(res, 200, { ok: true, hasChanges, branch, changedFileCount, followups }, req);
13338
+ return jsonReply(res, 200, { ok: true, hasChanges, branch, changedFileCount, checkoutMode, followups }, req);
13339
+ } catch (e) {
13340
+ return jsonReply(res, e.statusCode || 400, { error: e.message }, req);
13341
+ }
13342
+ }},
13343
+
13344
+ { method: 'POST', path: '/api/pr-action/prepare-create-pr-worktree', desc: 'Stage a worktree-mode project\'s uncommitted live-checkout changes into an ISOLATED git worktree and restore the live checkout clean (P-cc-createpr-checkout-mode). The Create-PR action calls this for projects whose checkoutMode is "worktree" so the subsequent commit/push/PR happen off the operator tree — never committing to the live main. Safe ordering: the worktree is populated and verified BEFORE the live checkout is touched; on apply failure the worktree is removed and the live checkout is left untouched. Returns {ok, worktreePath, branch, baseBranch, baseSha, trackedChanged, untrackedCount}; {ok:false, reason:"no-changes"} when the tree is clean.', params: 'project (configured project name), branch (optional preferred branch name)', handler: async (req, res) => {
13345
+ const body = await readBody(req);
13346
+ try {
13347
+ reloadConfig();
13348
+ const projectName = typeof body?.project === 'string' ? body.project.trim() : '';
13349
+ if (!projectName) return jsonReply(res, 400, { error: 'project is required' }, req);
13350
+ const project = shared.getProjects(CONFIG).find(p => p && p.name === projectName);
13351
+ if (!project) return jsonReply(res, 404, { error: `unknown project: ${projectName}` }, req);
13352
+ if (!project.localPath) return jsonReply(res, 400, { error: `project ${projectName} has no localPath` }, req);
13353
+ const result = await createPrWorktree.prepareCreatePrWorktree({
13354
+ project,
13355
+ branch: typeof body?.branch === 'string' ? body.branch : undefined,
13356
+ minionsDir: MINIONS_DIR,
13357
+ engine: CONFIG.engine,
13358
+ log: (lvl, msg) => console.log(msg),
13359
+ });
13360
+ return jsonReply(res, 200, result, req);
13361
+ } catch (e) {
13362
+ return jsonReply(res, e.statusCode || 400, { error: e.message }, req);
13363
+ }
13364
+ }},
13365
+
13366
+ { method: 'POST', path: '/api/pr-action/cleanup-create-pr-worktree', desc: 'Remove an isolated worktree created by /api/pr-action/prepare-create-pr-worktree once its PR has been pushed/opened (P-cc-createpr-checkout-mode). Refuses any path missing the minions ownership marker, so it can never delete an arbitrary directory.', params: 'project (configured project name), worktreePath (the path returned by prepare-create-pr-worktree)', handler: async (req, res) => {
13367
+ const body = await readBody(req);
13368
+ try {
13369
+ reloadConfig();
13370
+ const projectName = typeof body?.project === 'string' ? body.project.trim() : '';
13371
+ const project = projectName ? shared.getProjects(CONFIG).find(p => p && p.name === projectName) : null;
13372
+ const worktreePath = typeof body?.worktreePath === 'string' ? body.worktreePath.trim() : '';
13373
+ if (!worktreePath) return jsonReply(res, 400, { error: 'worktreePath is required' }, req);
13374
+ const result = await createPrWorktree.cleanupCreatePrWorktree({ worktreePath, project });
13375
+ return jsonReply(res, 200, result, req);
13287
13376
  } catch (e) {
13288
13377
  return jsonReply(res, e.statusCode || 400, { error: e.message }, req);
13289
13378
  }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * engine/create-pr-worktree.js — checkout-mode-aware Create-PR staging.
3
+ *
4
+ * Background (the bug this fixes): the CC "Create PR" flow has the LLM commit /
5
+ * branch / push directly in the project's LIVE operator checkout
6
+ * (`project.localPath`). For a `checkoutMode: "worktree"` project that is wrong —
7
+ * a misstep by the LLM (committing before branching, freelancing `git pull`)
8
+ * leaks commits / merges onto the operator's `main`. The Create-PR action must
9
+ * follow the SAME checkout pattern as a normal dispatch for that project:
10
+ * - `live` → operate in the live checkout (handled by the existing flow);
11
+ * - `worktree` → operate in an isolated git worktree, never the live checkout.
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.
19
+ *
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.
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const shared = require('./shared');
28
+
29
+ const PATCH_APPLY_TIMEOUT_MS = 120000;
30
+
31
+ /**
32
+ * Move a worktree-mode project's uncommitted live-checkout changes into an
33
+ * isolated worktree and restore the live checkout clean.
34
+ *
35
+ * @returns {Promise<{ ok:true, worktreePath, branch, baseBranch, baseSha,
36
+ * 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).
39
+ */
40
+ async function prepareCreatePrWorktree({
41
+ project, branch, minionsDir, engine, log = () => {}, _git, _fs,
42
+ } = {}) {
43
+ const git = _git || ((args, opts) => shared.shellSafeGit(args, opts));
44
+ const fsm = _fs || fs;
45
+ if (!project || typeof project !== 'object') {
46
+ throw new Error('prepareCreatePrWorktree: project object required');
47
+ }
48
+ const localPath = project.localPath;
49
+ if (!localPath) {
50
+ throw new Error('prepareCreatePrWorktree: project.localPath required');
51
+ }
52
+ const mainBranch = (project.mainBranch && String(project.mainBranch).trim()) || 'main';
53
+
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)})`);
58
+ }
59
+ // `git diff HEAD --binary` captures staged + unstaged tracked changes
60
+ // (including deletions and binary deltas) as a single patch.
61
+ const trackedDiff = await git(['-C', localPath, 'diff', '--binary', 'HEAD']);
62
+ const porcelain = await git(['-C', localPath, 'status', '--porcelain']);
63
+ const untracked = porcelain
64
+ .split('\n')
65
+ .filter(l => l.startsWith('?? '))
66
+ .map(l => l.slice(3).trim())
67
+ .filter(Boolean);
68
+ const hasTracked = !!trackedDiff.trim();
69
+ if (!hasTracked && untracked.length === 0) {
70
+ return { ok: false, reason: 'no-changes' };
71
+ }
72
+
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).
76
+ const uid = shared.uid();
77
+ const sanitized = String(project.name || 'project').replace(/[^a-zA-Z0-9._-]/g, '-').slice(0, 40) || 'project';
78
+ const requested = branch && String(branch).trim();
79
+ const branchName = (requested && /^[\w./-]{1,80}$/.test(requested) && requested !== mainBranch)
80
+ ? requested
81
+ : `cc-pr/${sanitized}-${uid}`;
82
+
83
+ let projectRoot;
84
+ try { projectRoot = shared.resolveProjectRootDir(localPath, minionsDir); }
85
+ catch (e) { throw new Error(`prepareCreatePrWorktree: cannot resolve project root for ${localPath} — ${e.message}`); }
86
+ const wtRel = (engine && engine.worktreeRoot) || shared.ENGINE_DEFAULTS.worktreeRoot;
87
+ const worktreesBase = path.resolve(projectRoot, wtRel);
88
+ const wtPath = path.join(worktreesBase, `cc-createpr-${sanitized}-${uid}`);
89
+ try { fsm.mkdirSync(worktreesBase, { recursive: true }); } catch { /* best-effort; worktree add will surface a real failure */ }
90
+
91
+ await git(
92
+ ['-C', localPath, 'worktree', 'add', '-b', branchName, wtPath, baseSha],
93
+ { timeout: shared.ENGINE_DEFAULTS.worktreeCreateTimeout },
94
+ );
95
+ try { shared.writeWorktreeOwnerMarker(wtPath, { source: 'cc-create-pr', project: project.name }); } catch { /* marker is best-effort */ }
96
+
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.
99
+ try {
100
+ if (hasTracked) {
101
+ const patchFile = path.join(wtPath, `.cc-createpr-${uid}.patch`);
102
+ fsm.writeFileSync(patchFile, trackedDiff);
103
+ await git(['-C', wtPath, 'apply', '--whitespace=nowarn', patchFile], { timeout: PATCH_APPLY_TIMEOUT_MS });
104
+ try { fsm.unlinkSync(patchFile); } catch { /* ignore */ }
105
+ }
106
+ for (const rel of untracked) {
107
+ // `?? dir/` entries from porcelain are directories — copy their tree.
108
+ const src = path.join(localPath, rel);
109
+ const dst = path.join(wtPath, rel);
110
+ _copyRecursive(fsm, src, dst);
111
+ }
112
+ } catch (e) {
113
+ try { await git(['-C', localPath, 'worktree', 'remove', '--force', wtPath]); } catch { /* leak rather than throw a second error */ }
114
+ throw new Error(
115
+ `prepareCreatePrWorktree: failed to stage changes into the worktree — ${e.message}. ` +
116
+ 'The live checkout was left untouched.',
117
+ );
118
+ }
119
+
120
+ // 4. The worktree now holds the changes — restore the live checkout clean.
121
+ await git(['-C', localPath, 'checkout', '--', '.']);
122
+ for (const rel of untracked) {
123
+ try { fsm.rmSync(path.join(localPath, rel.replace(/\/$/, '')), { recursive: true, force: true }); } catch { /* ignore */ }
124
+ }
125
+
126
+ log('info', `[cc-create-pr] staged ${project.name} changes into isolated worktree ${wtPath} on branch ${branchName} (live checkout restored)`);
127
+ return {
128
+ ok: true,
129
+ worktreePath: wtPath,
130
+ branch: branchName,
131
+ baseBranch: mainBranch,
132
+ baseSha,
133
+ trackedChanged: hasTracked,
134
+ untrackedCount: untracked.length,
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Remove a worktree previously created by prepareCreatePrWorktree. Refuses to
140
+ * touch a path that doesn't carry the minions ownership marker (so a bad/forged
141
+ * path can never delete an arbitrary directory).
142
+ */
143
+ async function cleanupCreatePrWorktree({ worktreePath, project, _git, _fs } = {}) {
144
+ const git = _git || ((args, opts) => shared.shellSafeGit(args, opts));
145
+ const fsm = _fs || fs;
146
+ if (!worktreePath || typeof worktreePath !== 'string') {
147
+ throw new Error('cleanupCreatePrWorktree: worktreePath required');
148
+ }
149
+ if (!fsm.existsSync(worktreePath)) {
150
+ return { ok: true, alreadyGone: true };
151
+ }
152
+ // Guard: only ever remove a directory the engine stamped as its own worktree.
153
+ if (!shared.hasWorktreeOwnerMarker(worktreePath)) {
154
+ return { ok: false, reason: 'not-owned' };
155
+ }
156
+ const fromDir = (project && project.localPath) || worktreePath;
157
+ try {
158
+ await git(['-C', fromDir, 'worktree', 'remove', '--force', worktreePath]);
159
+ } catch (e) {
160
+ return { ok: false, reason: 'remove-failed', error: e.message };
161
+ }
162
+ return { ok: true };
163
+ }
164
+
165
+ // Copy a file or directory tree (untracked entries may be either).
166
+ function _copyRecursive(fsm, src, dst) {
167
+ const st = fsm.statSync(src);
168
+ if (st.isDirectory()) {
169
+ fsm.mkdirSync(dst, { recursive: true });
170
+ for (const entry of fsm.readdirSync(src)) {
171
+ _copyRecursive(fsm, path.join(src, entry), path.join(dst, entry));
172
+ }
173
+ } else {
174
+ fsm.mkdirSync(path.dirname(dst), { recursive: true });
175
+ fsm.copyFileSync(src, dst);
176
+ }
177
+ }
178
+
179
+ module.exports = { prepareCreatePrWorktree, cleanupCreatePrWorktree };
@@ -1966,6 +1966,89 @@ async function enforcePrAttachmentContract(type, meta, agentId, config, resultSu
1966
1966
  return { reason, itemId: meta.item.id, severity, phantom: isPhantom };
1967
1967
  }
1968
1968
 
1969
+ // W-mqsk1ip00006cbae — After a verify WI transitions to done, assert that a PR
1970
+ // exists: either a direct reference on the item (_pr/_prUrl) or a matching
1971
+ // entry in pull-requests.json for the plan (by sourcePlan). If no PR is found,
1972
+ // flip the WI to failed with failure_class: verify-missing-pr and write an
1973
+ // inbox alert so operators can investigate.
1974
+ //
1975
+ // Returns null on the happy path (PR found or type is not verify).
1976
+ // Returns { reason, severity: 'hard', failureClass: 'verify-missing-pr' } when
1977
+ // the guard fires so the caller can set skipDoneStatus = true.
1978
+ function enforceVerifyPrContract(type, meta, agentId, config, resultSummary) {
1979
+ if (type !== WORK_TYPE.VERIFY) return null;
1980
+ if (!meta?.item?.id) return null;
1981
+
1982
+ const item = meta.item;
1983
+
1984
+ // Fast path: direct PR reference stamped on the WI.
1985
+ if (item._pr || item._prUrl) return null;
1986
+
1987
+ // Canonical tracker check: does any PR record list this WI in prdItems?
1988
+ try {
1989
+ if (hasCanonicalPrAttachment(item.id, config)) return null;
1990
+ } catch (err) {
1991
+ // If the check throws (e.g. unreadable PR file) we cannot confirm a PR
1992
+ // exists, so fall through to the failure path.
1993
+ log('warn', `enforceVerifyPrContract: canonical PR check failed for ${item.id}: ${err.message}`);
1994
+ }
1995
+
1996
+ // Plan-level check: any PR with matching sourcePlan in project PR files.
1997
+ if (item.sourcePlan) {
1998
+ try {
1999
+ const projects = shared.getProjects(config);
2000
+ for (const p of projects) {
2001
+ const prs = readOptionalJsonStrict(shared.projectPrPath(p), 'project pull-requests', Array.isArray) || [];
2002
+ if (prs.some(pr => pr.sourcePlan === item.sourcePlan)) return null;
2003
+ }
2004
+ const centralPrs = readOptionalJsonStrict(path.join(MINIONS_DIR, 'pull-requests.json'), 'central pull-requests', Array.isArray) || [];
2005
+ if (centralPrs.some(pr => pr.sourcePlan === item.sourcePlan)) return null;
2006
+ } catch (err) {
2007
+ log('warn', `enforceVerifyPrContract: sourcePlan PR check failed for ${item.id}: ${err.message}`);
2008
+ }
2009
+ }
2010
+
2011
+ // No PR found — flip WI to failed and surface an alert.
2012
+ const failureClass = FAILURE_CLASS.VERIFY_MISSING_PR;
2013
+ const reason = `Verify WI ${item.id} completed done but no PR was found ` +
2014
+ `(no _prUrl/_pr on item, no canonical attachment, and no pull-requests.json entry for plan ` +
2015
+ `${item.sourcePlan || '(none)'}). Flipped to failed so the plan does not silently advance. ` +
2016
+ `(Branch: ${meta.branch || '(none)'}, agent: ${agentId})`;
2017
+
2018
+ const wiPath = resolveWorkItemPath(meta);
2019
+ if (wiPath) {
2020
+ mutateJsonFileLocked(wiPath, data => {
2021
+ if (!Array.isArray(data)) return data;
2022
+ const w = data.find(i => i.id === item.id);
2023
+ if (!w) return data;
2024
+ w.status = WI_STATUS.FAILED;
2025
+ w._failureClass = failureClass;
2026
+ w.failReason = reason;
2027
+ w.failedAt = ts();
2028
+ delete w.completedAt;
2029
+ return data;
2030
+ }, { skipWriteIfUnchanged: true });
2031
+ }
2032
+
2033
+ if (item.sourcePlan) {
2034
+ try { syncPrdItemStatus(item.id, WI_STATUS.FAILED, item.sourcePlan); } catch (e) { log('warn', `verify-missing-pr PRD sync: ${e.message}`); }
2035
+ }
2036
+
2037
+ shared.writeToInbox('engine', `verify-missing-pr-${item.id}`,
2038
+ `# Verify WI completed without a PR: ${item.id}\n\n` +
2039
+ `**Agent:** ${agentId}\n` +
2040
+ `**Work item:** \`${item.id}\` — ${item.title || ''}\n` +
2041
+ `**Plan:** ${item.sourcePlan || '(none)'}\n` +
2042
+ `**Branch:** ${meta.branch || '(none)'}\n\n` +
2043
+ `${reason}\n` +
2044
+ (resultSummary ? `\n## Agent summary\n${resultSummary}\n` : ''),
2045
+ null,
2046
+ { sourceItem: item.id, reason: 'verify-missing-pr' });
2047
+
2048
+ log('warn', reason);
2049
+ return { reason, itemId: item.id, severity: 'hard', failureClass };
2050
+ }
2051
+
1969
2052
  // ─── Post-Completion Hooks ──────────────────────────────────────────────────
1970
2053
 
1971
2054
  /**
@@ -5141,6 +5224,17 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5141
5224
  }
5142
5225
  }
5143
5226
 
5227
+ // W-mqsk1ip00006cbae — verify WI PR guard. After enforcePrAttachmentContract
5228
+ // (which skips verify types), assert that a verify WI has a PR before
5229
+ // allowing the done transition.
5230
+ if (effectiveSuccess && meta?.item?.id && !skipDoneStatus && !noopRationale) {
5231
+ const verifyContractFailure = enforceVerifyPrContract(type, meta, agentId, config, resultSummary);
5232
+ if (verifyContractFailure?.severity === 'hard') {
5233
+ completionContractFailure = completionContractFailure || verifyContractFailure;
5234
+ skipDoneStatus = true;
5235
+ }
5236
+ }
5237
+
5144
5238
  if (effectiveSuccess && meta?.item?.id && !skipDoneStatus) {
5145
5239
  meta._agentId = agentId;
5146
5240
  if (noopRationale) {
@@ -5946,6 +6040,7 @@ module.exports = {
5946
6040
  deferNonTerminalCompletion,
5947
6041
  deferPhantomCompletion,
5948
6042
  enforcePrAttachmentContract,
6043
+ enforceVerifyPrContract,
5949
6044
  markMissingPrAttachment,
5950
6045
  parseCompletionReportFile,
5951
6046
  resolveHarnessPropagated,
@@ -146,36 +146,62 @@ const CREATE_PR_FOLLOWUP = Object.freeze({ kind: 'create-pr', label: 'Create PR'
146
146
  * well-named branch" wording when absent.
147
147
  * `contextOnly` — when true, the chip links the new PR as context-only instead
148
148
  * of auto-managed (the default is auto-managed / contextOnly:false).
149
+ * `checkoutMode` — the project's resolved checkout pattern
150
+ * (`shared.resolveCheckoutMode(project)`). The Create-PR action
151
+ * MUST follow the same pattern as a normal dispatch for that
152
+ * project: `'live'` operates in the live operator checkout
153
+ * (legacy behavior); `'worktree'` (the default) routes the
154
+ * changes through an isolated git worktree so nothing is ever
155
+ * committed in the operator's live `main`. Defaults to `'live'`
156
+ * for back-compat when a caller doesn't resolve it.
149
157
  *
150
158
  * Each chip: `{ kind:'create-pr', label, project, branch, message }` where
151
159
  * `message` is the Command Center turn the chip click should send.
152
160
  */
153
- function buildCreatePrFollowups({ project, branch, contextOnly = false } = {}) {
161
+ function buildCreatePrFollowups({ project, branch, contextOnly = false, checkoutMode = 'live' } = {}) {
154
162
  const proj = (project && String(project).trim()) || '';
155
163
  if (!proj) return [];
156
164
  const br = branch && String(branch).trim() ? String(branch).trim() : '';
157
- const branchClause = br
158
- ? `(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)`
159
- : 'on a new well-named branch off the project main branch';
160
165
  const linkBody = contextOnly
161
166
  ? `{"url":"<new PR url>","project":"${proj}","contextOnly":true}`
162
167
  : `{"url":"<new PR url>","project":"${proj}","contextOnly":false}`;
163
168
  const trackNote = contextOnly
164
169
  ? 'so it is tracked but not auto-reviewed'
165
170
  : 'so the engine auto-manages it (review -> fix -> re-review -> auto-merge)';
166
- // The original branch CC must return to once the PR is open, so the new PR
167
- // branch is never left checked out in the shared operator working tree.
168
- const restoreClause = br
169
- ? `switch the working tree back to the ORIGINAL branch (${br})`
170
- : 'switch the working tree back to the original branch it started on';
171
- const message =
172
- `Create a PR from the local changes you just made in the ${proj} project. ` +
173
- (br ? `Remember the working tree's current branch (${br}) as the ORIGINAL branch to return to when done. ` : '') +
174
- `Steps: (1) in that project's working tree, stage and commit the modified files with a clear conventional-commit message ${branchClause}; ` +
175
- `(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); ` +
176
- `(4) link it to the tracker by calling POST /api/pull-requests/link with ${linkBody} ${trackNote}; ` +
177
- `(5) finally, ${restoreClause} do not leave the new PR branch checked out. ` +
178
- `Then show me the PR URL.`;
171
+
172
+ let message;
173
+ if (checkoutMode === 'worktree') {
174
+ // Worktree-mode projects: the server moves the live-checkout changes into an
175
+ // isolated worktree and restores the live checkout clean, so the commit /
176
+ // push / PR all happen OFF the operator's tree. CC never runs git in the
177
+ // live checkout that is what kept leaking commits onto `main`.
178
+ message =
179
+ `Create a PR from the local changes you just made in the ${proj} project. ` +
180
+ `This project uses isolated worktrees, so do NOT commit, branch, or push in its live checkout. ` +
181
+ `Steps: (1) call POST /api/pr-action/prepare-create-pr-worktree with {"project":"${proj}"} — the server moves your uncommitted changes into a fresh isolated worktree, restores the live checkout clean, and returns {worktreePath, branch, baseBranch}; ` +
182
+ `(2) run all git from inside worktreePath (use \`git -C <worktreePath> ...\`): stage and commit the changes on the returned branch with a clear conventional-commit message; ` +
183
+ `(3) push that branch; (4) open a PR against baseBranch using the right CLI for the repo host (gh for GitHub, az repos for ADO); ` +
184
+ `(5) link it by calling POST /api/pull-requests/link with ${linkBody} ${trackNote}; ` +
185
+ `(6) finally call POST /api/pr-action/cleanup-create-pr-worktree with {"project":"${proj}","worktreePath":"<worktreePath>"} to remove the worktree. ` +
186
+ `Never git-commit/-push in the live checkout. Then show me the PR URL.`;
187
+ } 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';
193
+ const restoreClause = br
194
+ ? `switch the working tree back to the ORIGINAL branch (${br})`
195
+ : 'switch the working tree back to the original branch it started on';
196
+ message =
197
+ `Create a PR from the local changes you just made in the ${proj} project. ` +
198
+ (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. ` +
203
+ `Then show me the PR URL.`;
204
+ }
179
205
  return [{ kind: CREATE_PR_FOLLOWUP.kind, label: CREATE_PR_FOLLOWUP.label, project: proj, branch: br || null, message }];
180
206
  }
181
207
 
package/engine/queries.js CHANGED
@@ -1658,6 +1658,11 @@ let _kbCache = null; // last good snapshot — never nulled by invalidate
1658
1658
  let _kbCacheTs = 0;
1659
1659
  let _kbCacheStale = true; // invalidate marks stale; snapshot kept for sync readers
1660
1660
  let _kbRefreshPromise = null; // in-flight scan dedupe
1661
+ // Incremental-scan cache: filePath -> { mtimeMs, byteSize, entry }. Lets a
1662
+ // rescan reuse the derived entry for any file whose mtime+size are unchanged,
1663
+ // so the expensive readFile+parse only runs for genuinely-changed files. Keyed
1664
+ // off the live stat, so in-place edits (agent memory appends) are still caught.
1665
+ let _kbFileMetaCache = new Map();
1661
1666
  const KB_CACHE_TTL = 30000; // 30s — KB changes infrequently
1662
1667
 
1663
1668
  function invalidateKnowledgeBaseCache() {
@@ -1684,32 +1689,28 @@ async function _scanKnowledgeBase() {
1684
1689
  // ~30 large KB entries. `_flat()` materialises a fresh flat string via
1685
1690
  // Buffer round-trip so the cached entry no longer pins the parent file.
1686
1691
  const _flat = (s) => Buffer.from(String(s || ''), 'utf8').toString('utf8');
1687
- // Build the full (category, file) work list first, THEN process it with a
1688
- // bounded concurrency window. The KB tree holds thousands of files; the old
1689
- // unbounded `Promise.all(files.map(...))` per category fired thousands of
1690
- // concurrent readFile+stat ops at once, flooding the (default-4, now 16)
1691
- // libuv thread pool and starving every other fs-dependent dashboard request
1692
- // including the /api/status poll for tens of seconds, which is what
1693
- // tripped the "stale / unreachable" banner. Capping in-flight reads keeps
1694
- // the scan from monopolizing the pool. (Load-reduction audit 2026-06-24.)
1692
+ // Incremental, bounded-concurrency scan. The KB tree holds thousands of files;
1693
+ // re-reading every file's full content on each cache rebuild was the dominant
1694
+ // disk load behind dashboard staleness (the readFile + parse + string-alloc of
1695
+ // ~3.5k files, repeated every TTL expiry, on a disk already contended by live
1696
+ // agents). Two defenses: (1) a per-file metadata cache keyed on mtime+size, so
1697
+ // a rescan only re-reads files that actually changed steady state is one
1698
+ // light stat() per file and ~zero readFile; (2) a bounded in-flight window so
1699
+ // the scan can't flood the libuv thread pool and starve /api/status (which
1700
+ // trips the "stale" banner). (Load-reduction audit 2026-06-24.)
1695
1701
  const work = [];
1696
1702
  for (const cat of KB_CATEGORIES) {
1697
1703
  const catDir = path.join(KNOWLEDGE_DIR, cat);
1698
1704
  const files = (await fsp.readdir(catDir).catch(() => [])).filter(f => f.endsWith('.md'));
1699
1705
  for (const f of files) work.push({ cat, catDir, f });
1700
1706
  }
1701
- const scanOne = async ({ cat, catDir, f }) => {
1702
- const filePath = path.join(catDir, f);
1703
- const [content, stat] = await Promise.all([
1704
- fsp.readFile(filePath, 'utf8').catch(() => ''),
1705
- fsp.stat(filePath).catch(() => null),
1706
- ]);
1707
+ const nextMetaCache = new Map();
1708
+ const deriveEntry = (cat, f, content, sortTs) => {
1707
1709
  const titleMatch = content.match(/^#\s+(.+)/m);
1708
1710
  const title = _flat(titleMatch ? titleMatch[1].trim() : f.replace(/\.md$/, ''));
1709
1711
  const agentMatch = f.match(/^\d{4}-\d{2}-\d{2}-(\w+)-/);
1710
1712
  const dateMatch = f.match(/^(\d{4}-\d{2}-\d{2})/) || content.match(/^date:\s*(\d{4}-\d{2}-\d{2})$/m);
1711
1713
  const sourceMatch = content.match(/^source:\s*(.+)/m);
1712
- const sortTs = (stat && stat.mtimeMs) || 0;
1713
1714
  const displayDate = dateMatch ? _flat(dateMatch[1]) : (sortTs ? new Date(sortTs).toISOString().slice(0, 10) : '');
1714
1715
  return {
1715
1716
  cat, file: f, title,
@@ -1721,6 +1722,22 @@ async function _scanKnowledgeBase() {
1721
1722
  size: content.length,
1722
1723
  };
1723
1724
  };
1725
+ const scanOne = async ({ cat, catDir, f }) => {
1726
+ const filePath = path.join(catDir, f);
1727
+ const stat = await fsp.stat(filePath).catch(() => null);
1728
+ const sortTs = (stat && stat.mtimeMs) || 0;
1729
+ // Reuse the cached derived entry when mtime+size are unchanged — skips the
1730
+ // readFile + regex parse + string allocations entirely for stable files.
1731
+ const cached = _kbFileMetaCache.get(filePath);
1732
+ if (cached && stat && cached.mtimeMs === stat.mtimeMs && cached.byteSize === stat.size) {
1733
+ nextMetaCache.set(filePath, cached);
1734
+ return cached.entry;
1735
+ }
1736
+ const content = await fsp.readFile(filePath, 'utf8').catch(() => '');
1737
+ const entry = deriveEntry(cat, f, content, sortTs);
1738
+ nextMetaCache.set(filePath, { mtimeMs: sortTs, byteSize: stat ? stat.size : Buffer.byteLength(content), entry });
1739
+ return entry;
1740
+ };
1724
1741
  const entries = [];
1725
1742
  const KB_SCAN_CONCURRENCY = 16;
1726
1743
  let _next = 0;
@@ -1731,6 +1748,8 @@ async function _scanKnowledgeBase() {
1731
1748
  }
1732
1749
  };
1733
1750
  await Promise.all(Array.from({ length: Math.min(KB_SCAN_CONCURRENCY, work.length) }, worker));
1751
+ // Swap in the rebuilt cache (drops metadata for files that were deleted).
1752
+ _kbFileMetaCache = nextMetaCache;
1734
1753
  entries.sort((a, b) =>
1735
1754
  (b.sortTs || 0) - (a.sortTs || 0) ||
1736
1755
  (b.date || '').localeCompare(a.date || '') ||
@@ -2758,6 +2777,11 @@ function _projectGitRefFiles(localPath, configuredMainBranch) {
2758
2777
  if (!gitDir) return null;
2759
2778
  const commonGitDir = _resolveCommonGitDir(gitDir);
2760
2779
  const files = [
2780
+ // opg-microsoft/minions#381 — track HEAD directly so a bare `git checkout`
2781
+ // (which rewrites .git/HEAD but may not advance logs/HEAD when reflog is
2782
+ // disabled or when the snapshot already reflects the post-checkout mtime)
2783
+ // still triggers refsAdvanced=true and the gitStale marker on the next call.
2784
+ path.join(gitDir, 'HEAD'),
2761
2785
  path.join(gitDir, 'logs', 'HEAD'),
2762
2786
  path.join(commonGitDir, 'FETCH_HEAD'),
2763
2787
  ];
@@ -3149,10 +3173,14 @@ function getStatusSlowStateMtimePaths(config) {
3149
3173
  // not into the per-worktree subdir. For the main worktree both
3150
3174
  // resolvers return the same gitdir, so the behavior collapses to the
3151
3175
  // expected `<localPath>/.git/{logs/HEAD,FETCH_HEAD}` pair.
3176
+ // opg-microsoft/minions#381 — also track HEAD itself so `git checkout`
3177
+ // (which always rewrites HEAD) busts the cache even when logs/HEAD is
3178
+ // absent or was already captured at the post-checkout mtime.
3152
3179
  for (const project of projects) {
3153
3180
  if (!project || !project.localPath) continue;
3154
3181
  const gitDir = _resolveGitDir(project.localPath) || path.join(project.localPath, '.git');
3155
3182
  const commonGitDir = _resolveCommonGitDir(gitDir);
3183
+ files.push(path.join(gitDir, 'HEAD'));
3156
3184
  files.push(path.join(gitDir, 'logs', 'HEAD'));
3157
3185
  files.push(path.join(commonGitDir, 'FETCH_HEAD'));
3158
3186
  }
@@ -3183,6 +3211,7 @@ module.exports = {
3183
3211
  _setProbeWatchdogForTest,
3184
3212
  _getProbeTimeoutsForTest,
3185
3213
  _getProjectGitStatusCacheForTest,
3214
+ _resolveGitDir, // opg-microsoft/minions#381 — dashboard.js uses this to locate HEAD for fs.watch
3186
3215
  // W-mpftp7na000td0f4 — engine→dashboard cache-invalidation registry
3187
3216
  getStatusFastStateMtimePaths,
3188
3217
  getStatusSlowStateMtimePaths,
package/engine/shared.js CHANGED
@@ -4359,6 +4359,7 @@ const FAILURE_CLASS = {
4359
4359
  WORKSPACE_MANIFEST_URL: 'workspace-manifest-url-forbidden', // W-mq07avbk000m5543: out-of-scope external URL fetch. Non-retryable as-is.
4360
4360
  SPAWN_PHASE_STALL: 'spawn-phase-stall', // W-mq0e2dae000a003d: process spawned and ran startup-only events (MCP init / hooks) but never emitted real task progress; CPU usage stayed below threshold past the grace window. Engine kills the wedged child and treats this as retryable (fresh-session) so a re-spawn can clear a transient MCP wedge.
4361
4361
  OUTPUT_TRUNCATED: 'output-truncated', // P-8e4c2a17: the agent streamed more stdout than the engine's hard capture cap (engine.js AGENT_OUTPUT_CAP_BYTES, 1MB) BEFORE the terminal `result` event arrived. The result (session id, completion block, final text) lives at the END of the stream, so it fell outside the captured window and parseOutput found nothing — the dispatch would otherwise fail as an opaque UNKNOWN and retry to death. Surfaced loudly with an actionable message; non-retryable (mechanical retry just reproduces the overflow — the agent must reduce output volume or the task must be split).
4362
+ VERIFY_MISSING_PR: 'verify-missing-pr', // W-mqsk1ip00006cbae: a verify WI exited done but no PR was attached (neither _prUrl/_pr on the item nor a matching pull-requests.json entry for the plan). Flipped to failed so the plan doesn't silently advance without an E2E PR. Retryable — agent may have phantom-crashed before pushing the branch.
4362
4363
  UNKNOWN: 'unknown', // Unclassified failure
4363
4364
  };
4364
4365
  const ESCALATION_POLICY = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2251",
3
+ "version": "0.1.2253",
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"
@@ -96,7 +96,11 @@ curl -s -X POST http://localhost:{{dashboard_port}}/api/pr-action/offer-create-p
96
96
  -H 'Content-Type: application/json' -H 'X-CC-Turn-Id: {{cc_turn_id}}' \
97
97
  -d '{"project":"<project name>"}'
98
98
  ```
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 instructing you to commit → push → open the PR → link it to the tracker → switch the working tree back to the original branch (never leave the new PR branch checked out in the shared operator tree). 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.
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.
102
+
103
+ 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.
100
104
 
101
105
  ## When to dispatch vs answer inline
102
106