@yemi33/minions 0.1.2126 → 0.1.2128

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.
@@ -33,8 +33,14 @@ function showToast(id, msg, ok, durationMs) {
33
33
  setTimeout(() => { el.classList.remove('success', 'error'); }, durationMs || (msg.includes('<a ') ? 15000 : 4000));
34
34
  }
35
35
 
36
- function detectWorkItemType(text) {
36
+ function detectWorkItemType(text, hasPrRef) {
37
37
  const t = text.toLowerCase();
38
+ // When the caller doesn't pass an explicit PR-ref signal, derive it
39
+ // from the same text we're classifying. cmdParseInput passes the
40
+ // signal from RAW input (before `#projects` stripping) so canonical
41
+ // `#NNN` shorthand isn't lost; direct callers and tests get the
42
+ // ergonomic default of "scan the input you have."
43
+ const prRefPresent = (hasPrRef === undefined) ? _hasPrRef(text) : !!hasPrRef;
38
44
  const patterns = [
39
45
  { type: 'ask', words: ['explain', 'why does', 'why is', 'what does', 'how do i', 'how do you', 'what\'s the', 'tell me', 'can you explain', 'walk me through'] },
40
46
  { type: 'explore', words: ['explore', 'investigate', 'understand', 'analyze', 'audit', 'document', 'architecture', 'how does', 'what is', 'look into', 'research', 'survey', 'map out', 'codebase', 'make a note of', 'find out'] },
@@ -45,14 +51,51 @@ function detectWorkItemType(text) {
45
51
  { type: 'test', words: ['test', 'write tests', 'add tests', 'unit test', 'e2e test', 'coverage', 'testing', 'build', 'run locally', 'localhost', 'start the', 'spin up', 'verify', 'check if it works'] },
46
52
  ];
47
53
  for (const { type, words } of patterns) {
48
- if (words.some(w => t.includes(w))) return type;
54
+ if (words.some(w => t.includes(w))) {
55
+ // W-mq17dun7000f1150 — `type:fix` has a specific engine semantic
56
+ // ("responds to comments/builds on a tracked PR"). If the user's
57
+ // text matches the fix-verb list but doesn't reference any PR, the
58
+ // intent is a fresh repo-level bugfix that will open a NEW PR —
59
+ // i.e. `implement`, not `fix`. Without this gate, repo-level
60
+ // bugfix WIs hit the engine.js#discoverWork `pr_not_found` gate
61
+ // when shared.extractWorkItemPrRef finds a stale PR pointer in
62
+ // the description text and the WI is stuck indefinitely.
63
+ if (type === 'fix' && !prRefPresent) return 'implement';
64
+ return type;
65
+ }
49
66
  }
50
67
  return 'implement';
51
68
  }
52
69
 
70
+ // _hasPrRef(rawText) -> bool. Recognises canonical PR pointers in free text:
71
+ // - `PR #N` / `PR-N` / `PR N`
72
+ // - `pull request N`
73
+ // - GitHub pull URLs: `github.com/<owner>/<repo>/pull/<N>`
74
+ // - Azure DevOps pull URLs: `.../pullrequest/<N>`
75
+ // - Canonical ids: `github:owner/repo#N`, `ado:org/proj/repo#N`
76
+ // Does NOT match GitHub `issues/N` or bare `#N` — those are not PR pointers
77
+ // (issues have their own URL shape and bare `#N` collides with hashtag /
78
+ // project syntax in cmdParseInput).
79
+ function _hasPrRef(rawText) {
80
+ if (!rawText || typeof rawText !== 'string') return false;
81
+ if (/\bpr[\s#-]+\d+\b/i.test(rawText)) return true;
82
+ if (/\bpull\s+request[\s#-]*\d+\b/i.test(rawText)) return true;
83
+ if (/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/i.test(rawText)) return true;
84
+ if (/\/pullrequest\/\d+/i.test(rawText)) return true;
85
+ if (/\bgithub:[\w.-]+\/[\w.-]+#\d+/i.test(rawText)) return true;
86
+ if (/\bado:[\w.-]+\/[\w.-]+\/[\w.-]+#\d+/i.test(rawText)) return true;
87
+ return false;
88
+ }
89
+
53
90
  // Parse the unified input into structured intent
54
91
  function cmdParseInput(raw) {
55
92
  let text = raw.trim();
93
+ // W-mq17dun7000f1150 — capture the PR-ref signal on the RAW input before
94
+ // we strip `#projects` (which would eat `#NNN` PR shorthand). The signal
95
+ // is passed to detectWorkItemType so a naked "fix" verb without a PR
96
+ // pointer gets demoted to `implement` (the engine semantic for `fix`
97
+ // requires a tracked PR).
98
+ const hasPrRef = _hasPrRef(raw);
56
99
  const result = {
57
100
  intent: 'work-item', // 'work-item' | 'note' | 'plan'
58
101
  agents: [], // assigned agent IDs
@@ -138,9 +181,9 @@ function cmdParseInput(raw) {
138
181
  result.description = lines.slice(1).join('\n').trim();
139
182
 
140
183
  // Auto-detect work type
141
- result.type = detectWorkItemType(result.title + ' ' + result.description);
184
+ result.type = detectWorkItemType(result.title + ' ' + result.description, hasPrRef);
142
185
 
143
186
  return result;
144
187
  }
145
188
 
146
- window.MinionsCmdParser = { cmdUpdateAgentList, cmdUpdateProjectList, showToast, detectWorkItemType, cmdParseInput };
189
+ window.MinionsCmdParser = { cmdUpdateAgentList, cmdUpdateProjectList, showToast, detectWorkItemType, cmdParseInput, _hasPrRef };
@@ -297,6 +297,7 @@ async function openSettings() {
297
297
  settingsField('Worktree Create Timeout', 'set-worktreeCreateTimeout', e.worktreeCreateTimeout || 300000, 'ms', 'Timeout for git worktree add (increase for large repos/Windows)') +
298
298
  settingsField('Worktree Create Retries', 'set-worktreeCreateRetries', e.worktreeCreateRetries || 1, '', 'Retry count for transient worktree add failures (0-3)') +
299
299
  settingsField('Worktree Root', 'set-worktreeRoot', e.worktreeRoot || '../worktrees', '', 'Relative or absolute path for git worktrees; on Windows prefer a short path like C:\\wt') +
300
+ settingsField('Assert-Clean Status Probe Timeout', 'set-assertCleanStatusTimeoutMs', e.assertCleanStatusTimeoutMs || 10000, 'ms', 'Timeout for the `git status --porcelain` preflight probe in assertCleanSharedWorktree. Raise this (e.g. 60000) on GVFS/Plastic-backed monorepos where sparse hydration runs longer than the 10s default. On timeout the engine quarantines the bad worktree and emits a RETRYABLE failure so the next dispatch starts fresh. Clamped 1000–120000ms.') +
300
301
  '</div>';
301
302
 
302
303
  const paneCopilot =
@@ -846,6 +847,7 @@ async function saveSettings() {
846
847
  worktreeCreateTimeout: document.getElementById('set-worktreeCreateTimeout').value,
847
848
  worktreeCreateRetries: document.getElementById('set-worktreeCreateRetries').value,
848
849
  worktreeRoot: document.getElementById('set-worktreeRoot').value,
850
+ assertCleanStatusTimeoutMs: document.getElementById('set-assertCleanStatusTimeoutMs').value,
849
851
  idleAlertMinutes: document.getElementById('set-idleAlertMinutes').value,
850
852
  shutdownTimeout: document.getElementById('set-shutdownTimeout').value,
851
853
  restartGracePeriod: document.getElementById('set-restartGracePeriod').value,
package/dashboard.js CHANGED
@@ -3335,6 +3335,14 @@ function getWorkItemPrRef(input) {
3335
3335
  // description text — not just the canonical structured fields — and routes
3336
3336
  // the dispatch to the existing PR branch instead of a fresh `work/<wi-id>`
3337
3337
  // parallel branch (issue #2999 / W-mpx6i5kh000ac040).
3338
+ //
3339
+ // ASYMMETRY (W-mq18ec6h000p7b87): the engine's pr_not_found *gate* uses
3340
+ // the strict `shared.extractStructuredWorkItemPrRef` — gate uses
3341
+ // structured-only; stamp uses loose. Rationale: operators creating fix
3342
+ // WIs via API often paste the PR URL in description prose and expect
3343
+ // `targetPr` to get auto-stamped here (best-effort, reversible). The gate
3344
+ // blocks dispatch and must require explicit operator intent (a structured
3345
+ // field) before doing so.
3338
3346
  return shared.extractWorkItemPrRef(input);
3339
3347
  }
3340
3348
 
@@ -5486,6 +5494,20 @@ const server = http.createServer(async (req, res) => {
5486
5494
  // central WI silently land and loop. (W-mp631ux9000x6df0; reproduced by
5487
5495
  // W-mp629rn3000od581 — Rebecca's spawn looped 12+ times silently.)
5488
5496
  const normalizedType = routing.normalizeWorkType(body.type, WORK_TYPE.IMPLEMENT);
5497
+ // plan-to-prd must carry a planFile — without it the agent has no plan to
5498
+ // convert. engine.js #spawnAgent only populates `vars.plan_content` from
5499
+ // the plan file when `item.planFile` is set (see engine.js ~line 6710);
5500
+ // with no planFile that whole setup block is skipped, leaving the agent
5501
+ // to produce an orphaned PRD with no source_plan linkage. Engine-internal
5502
+ // chaining via `shared.queuePlanToPrd` always stamps planFile, so this
5503
+ // guard only gates external callers hitting POST /api/work-items
5504
+ // directly. (W-mq14txl5)
5505
+ if (normalizedType === WORK_TYPE.PLAN_TO_PRD
5506
+ && (!body.planFile || !String(body.planFile).trim())) {
5507
+ return jsonReply(res, 400, {
5508
+ error: 'planFile is required for type=plan-to-prd. Use POST /api/plan to create a new plan (the engine will auto-chain plan-to-prd) or POST /api/plans/execute for an existing .md draft.',
5509
+ });
5510
+ }
5489
5511
  if (WORKTREE_REQUIRING_TYPES.has(normalizedType)
5490
5512
  && PROJECTS.length !== 1
5491
5513
  && (!body.project || !String(body.project).trim())) {
@@ -5882,47 +5904,26 @@ const server = http.createServer(async (req, res) => {
5882
5904
  const requestedAgent = body.agent || body.agentId;
5883
5905
  const requestedTask = body.task || body.cancelTask;
5884
5906
  if (!requestedAgent && !requestedTask) return jsonReply(res, 400, { error: 'agent or task required' });
5907
+
5908
+ // Pre-read for response snapshot. Lockless — cleanDispatchEntries owns the actual mutation.
5885
5909
  const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
5886
5910
  const dispatch = safeJsonObj(dispatchPath);
5887
- const active = dispatch.active || [];
5888
- const cancelled = [];
5889
-
5890
- for (const d of active) {
5911
+ const active = Array.isArray(dispatch.active) ? dispatch.active : [];
5912
+ const matchFn = (d) => {
5891
5913
  const matchAgent = requestedAgent && d.agent === requestedAgent;
5892
5914
  const matchTask = requestedTask && (d.task || '').toLowerCase().includes(String(requestedTask).toLowerCase());
5893
- if (!matchAgent && !matchTask) continue;
5894
-
5895
- // Kill agent process
5896
- const statusPath = path.join(MINIONS_DIR, 'agents', d.agent, 'status.json');
5897
- try {
5898
- const status = safeJsonObj(statusPath);
5899
- if (status.pid) {
5900
- try {
5901
- const safePid = shared.validatePid(status.pid);
5902
- if (process.platform === 'win32') {
5903
- require('child_process').execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
5904
- } else {
5905
- process.kill(safePid, 'SIGTERM');
5906
- }
5907
- } catch { /* process may be dead or invalid PID */ }
5908
- }
5909
- status.status = 'idle';
5910
- delete status.currentTask;
5911
- delete status.dispatched;
5912
- safeWrite(statusPath, status);
5913
- } catch (e) { console.error('agent cancel:', e.message); }
5914
-
5915
- cancelled.push({ agent: d.agent, task: d.task });
5916
- }
5917
-
5918
- // Remove cancelled from active dispatch
5915
+ return Boolean(matchAgent || matchTask);
5916
+ };
5917
+ const cancelled = active.filter(matchFn).map(d => ({ agent: d.agent, task: d.task }));
5918
+
5919
+ // Route PID resolution + kill + dispatch removal through the canonical primitive.
5920
+ // cleanDispatchEntries resolves the PID from engine/tmp/dispatch-<id>-*/pid-<id>.pid
5921
+ // (see shared.findDispatchPidFile), kills outside the dispatch lock, and removes
5922
+ // the entry via mutateDispatch (SQL + JSON mirror in one atomic write).
5923
+ // The defunct per-agent status sidecar reads/writes that used to live here are gone
5924
+ // (engine/queries.js documents that file no longer exists).
5919
5925
  if (cancelled.length > 0) {
5920
- const cancelledIds = new Set(cancelled.map(c => c.agent));
5921
- mutateJsonFileLocked(dispatchPath, (dp) => {
5922
- dp.active = Array.isArray(dp.active) ? dp.active : [];
5923
- dp.active = dp.active.filter(d => !cancelledIds.has(d.agent));
5924
- return dp;
5925
- }, { defaultValue: { pending: [], active: [], completed: [] } });
5926
+ cleanDispatchEntries(matchFn);
5926
5927
  }
5927
5928
 
5928
5929
  return jsonReply(res, 200, { ok: true, cancelled });
@@ -6353,7 +6354,7 @@ const server = http.createServer(async (req, res) => {
6353
6354
  // safeJsonArr is sync but reads a single small file — leave as is.
6354
6355
  const centralWi = safeJsonArr(path.join(MINIONS_DIR, 'work-items.json'));
6355
6356
  const completedPrdFiles = new Set(
6356
- centralWi.filter(w => w.type === 'plan-to-prd' && DONE_STATUSES.has(w.status) && w.planFile)
6357
+ centralWi.filter(w => w.type === WORK_TYPE.PLAN_TO_PRD && DONE_STATUSES.has(w.status) && w.planFile)
6357
6358
  .map(w => w.planFile)
6358
6359
  );
6359
6360
  const plans = [];
@@ -6604,7 +6605,7 @@ const server = http.createServer(async (req, res) => {
6604
6605
 
6605
6606
  // Propagate pause to materialized work items across all projects:
6606
6607
  // kill any active agent process and reset non-completed items to paused.
6607
- // Pattern: collect-then-release-then-act to avoid nested locks and long lock holds.
6608
+ // Pattern: lockless reads cleanDispatchEntries (atomic kill + remove) → mutateWorkItems.
6608
6609
  const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
6609
6610
  for (const proj of PROJECTS) {
6610
6611
  wiPaths.push(shared.projectWorkItemsPath(proj));
@@ -6624,50 +6625,24 @@ const server = http.createServer(async (req, res) => {
6624
6625
  } catch { /* file may not exist */ }
6625
6626
  }
6626
6627
 
6627
- // Step 2: Read dispatch.json (read-only, no lock) to collect kill targets.
6628
- const killTargets = []; // { agent, pid, statusPath }
6629
- const dispatch = safeJsonObj(dispatchPath);
6630
- const activeEntries = Array.isArray(dispatch.active) ? dispatch.active : [];
6631
- for (const d of activeEntries) {
6632
- const itemId = d.meta?.item?.id;
6633
- const matchesById = itemId && dispatchedItemIds.has(itemId);
6634
- const matchesByKey = d.meta?.dispatchKey && [...dispatchedItemIds].some(id => d.meta.dispatchKey.includes(id));
6635
- if (matchesById || matchesByKey) {
6636
- const statusPath = path.join(MINIONS_DIR, 'agents', d.agent, 'status.json');
6637
- try {
6638
- const agentStatus = safeJsonObj(statusPath);
6639
- killTargets.push({ agent: d.agent, pid: agentStatus.pid || null, statusPath });
6640
- } catch { killTargets.push({ agent: d.agent, pid: null, statusPath }); }
6641
- }
6642
- }
6643
-
6644
- // Step 3: Kill agent processes OUTSIDE any lock (expensive, may take seconds).
6645
- const killedAgents = new Set();
6646
- for (const target of killTargets) {
6647
- if (target.pid) {
6648
- try {
6649
- const safePid = shared.validatePid(target.pid);
6650
- if (process.platform === 'win32') {
6651
- require('child_process').execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
6652
- } else {
6653
- process.kill(safePid, 'SIGTERM');
6654
- }
6655
- } catch { /* process may be dead or invalid PID */ }
6656
- }
6657
- // Reset agent status file (no lock needed — agent-specific file).
6658
- try {
6659
- const agentStatus = safeJsonObj(target.statusPath);
6660
- agentStatus.status = 'idle';
6661
- delete agentStatus.currentTask;
6662
- delete agentStatus.dispatched;
6663
- safeWrite(target.statusPath, agentStatus);
6664
- } catch (e) { console.error('agent reset:', e.message); }
6665
- killedAgents.add(target.agent);
6628
+ // Step 2: Route PID resolution + kill + dispatch removal through the canonical primitive.
6629
+ // cleanDispatchEntries resolves PIDs from engine/tmp/dispatch-<id>-*/pid-<id>.pid
6630
+ // (see shared.findDispatchPidFile), kills outside the dispatch lock, and removes the
6631
+ // entries via mutateDispatch (SQL + JSON mirror in one atomic write).
6632
+ // The defunct per-agent status sidecar reads/writes that used to live here are gone
6633
+ // (engine/queries.js documents that file no longer exists).
6634
+ if (dispatchedItemIds.size > 0) {
6635
+ const matchFn = (d) => {
6636
+ const itemId = d.meta?.item?.id;
6637
+ if (itemId && dispatchedItemIds.has(itemId)) return true;
6638
+ if (d.meta?.dispatchKey && [...dispatchedItemIds].some(id => d.meta.dispatchKey.includes(id))) return true;
6639
+ return false;
6640
+ };
6641
+ cleanDispatchEntries(matchFn);
6666
6642
  }
6667
6643
 
6668
- // Step 4: Mutate work-items.json per path — pause items (each lock held briefly, no nesting).
6644
+ // Step 3: Mutate work-items.json per path — pause items (each lock held briefly, no nesting).
6669
6645
  let reset = 0;
6670
- const resetItemIds = new Set();
6671
6646
  for (const wiPath of wiPaths) {
6672
6647
  try {
6673
6648
  mutateWorkItems(wiPath, items => {
@@ -6682,24 +6657,11 @@ const server = http.createServer(async (req, res) => {
6682
6657
  delete w.dispatched_to;
6683
6658
  delete w.failReason;
6684
6659
  delete w.failedAt;
6685
- if (w.id) resetItemIds.add(w.id);
6686
6660
  }
6687
6661
  });
6688
6662
  } catch (e) { console.error('reset work items:', e.message); }
6689
6663
  }
6690
6664
 
6691
- // Step 5: Re-acquire dispatch lock to clean up active entries (brief lock, no nesting).
6692
- mutateJsonFileLocked(dispatchPath, (dispatchData) => {
6693
- dispatchData.active = Array.isArray(dispatchData.active) ? dispatchData.active : [];
6694
- dispatchData.active = dispatchData.active.filter(d => {
6695
- const itemId = d.meta?.item?.id;
6696
- if (itemId && resetItemIds.has(itemId)) return false;
6697
- if (killedAgents.has(d.agent)) return false;
6698
- return true;
6699
- });
6700
- return dispatchData;
6701
- }, { defaultValue: { pending: [], active: [], completed: [] } });
6702
-
6703
6665
  invalidateStatusCache();
6704
6666
  invalidatePlansCache();
6705
6667
  return jsonReply(res, 200, { ok: true, status: 'paused', resetWorkItems: reset });
@@ -6873,7 +6835,7 @@ const server = http.createServer(async (req, res) => {
6873
6835
  const centralPath = path.join(MINIONS_DIR, 'work-items.json');
6874
6836
  mutateWorkItems(centralPath, items => {
6875
6837
  for (const w of items) {
6876
- if (w.type === 'plan-to-prd' && DONE_STATUSES.has(w.status) && w.planFile === prdSourcePlan) {
6838
+ if (w.type === WORK_TYPE.PLAN_TO_PRD && DONE_STATUSES.has(w.status) && w.planFile === prdSourcePlan) {
6877
6839
  w.status = WI_STATUS.CANCELLED;
6878
6840
  w._cancelledBy = 'prd-deleted';
6879
6841
  }
@@ -7020,7 +6982,7 @@ const server = http.createServer(async (req, res) => {
7020
6982
  mutateWorkItems(wiPath, items => {
7021
6983
  items.push({
7022
6984
  id, title: 'Revise plan: ' + (plan.plan_summary || body.file),
7023
- type: 'plan-to-prd', priority: 'high',
6985
+ type: WORK_TYPE.PLAN_TO_PRD, priority: 'high',
7024
6986
  description: 'Revision requested on plan file: ' + (body.file.endsWith('.json') ? 'prd/' : 'plans/') + body.file + '\n\nFeedback:\n' + body.feedback + '\n\nRevise the plan to address this feedback. Read the existing plan, apply the feedback, and overwrite the file with the updated version. Set status back to "awaiting-approval".',
7025
6987
  status: WI_STATUS.PENDING, created: new Date().toISOString(), createdBy: 'dashboard:revision',
7026
6988
  project: plan.project || '',
@@ -9223,6 +9185,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9223
9185
  tickInterval: [10000], maxConcurrent: [1, 50], inboxConsolidateThreshold: [1],
9224
9186
  agentTimeout: [60000], maxTurns: [5, 500], heartbeatTimeout: [60000],
9225
9187
  worktreeCreateTimeout: [60000], worktreeCreateRetries: [0, 3],
9188
+ // W-mq1habhf: status-probe deadline inside assertCleanSharedWorktree.
9189
+ // 1s floor (anything shorter races with git startup on cold caches);
9190
+ // 2min ceiling (covers even a GVFS-backed sparse hydration without
9191
+ // letting a config typo wedge dispatch for an hour).
9192
+ assertCleanStatusTimeoutMs: [1000, 120000],
9226
9193
  idleAlertMinutes: [1], shutdownTimeout: [30000], restartGracePeriod: [60000],
9227
9194
  meetingRoundTimeout: [60000],
9228
9195
  // W-mq066js7000fff1f-c (Gap B/C): steering safety-net knobs.
@@ -180,8 +180,15 @@ function getAdoGitExtraArgs(project, opts = {}) {
180
180
  return _composeAdoArgs(_buildHeaderArgs(token, _resolveScopeUrls(project)));
181
181
  } catch (e) {
182
182
  _backoffUntil = now + ACQUIRE_BACKOFF_MS;
183
- const firstLine = String(e && e.message || e).split('\n')[0];
184
- log('warn', `ado-git-auth: token acquire failed (${firstLine}); backing off ${ACQUIRE_BACKOFF_MS / 1000}s`);
183
+ // #3045 log the FULL exec error (redacted) instead of just the first
184
+ // line. child_process.exec rejections start with "Command failed: <cmd>"
185
+ // and put the real stderr ("TF400813 unknown user", "HTTP 401", etc.)
186
+ // on the SECOND line; .split('\n')[0] threw that away and made every
187
+ // ADO auth failure look like an indistinguishable wall of "Command
188
+ // failed: ...". _redactBearer strips any bearer header echoed back by
189
+ // the failing git command so the token never reaches engine logs.
190
+ const fullMsg = _redactBearer(String(e && e.message || e));
191
+ log('warn', `ado-git-auth: token acquire failed (${fullMsg}); backing off ${ACQUIRE_BACKOFF_MS / 1000}s`);
185
192
  return [...CREDENTIAL_DISABLE_ARGS];
186
193
  }
187
194
  }
@@ -272,6 +279,7 @@ module.exports = {
272
279
  isAdoAuthFailure,
273
280
  runAdoGit,
274
281
  isAdoProject,
282
+ redactBearer: _redactBearer,
275
283
  _setTokenForTest,
276
284
  _clearTokenCache,
277
285
  _isBackedOff,
@@ -23,7 +23,7 @@
23
23
 
24
24
  const path = require('path');
25
25
  const shared = require('./shared');
26
- const { safeJson, safeJsonArr, projectPrPath, getProjects } = shared;
26
+ const { safeJson, safeJsonArr, projectPrPath, getProjects, MINIONS_DIR } = shared;
27
27
 
28
28
  // ── Arg parsing ──────────────────────────────────────────────────────────────
29
29
 
@@ -87,7 +87,7 @@ function findInCache(projects) {
87
87
 
88
88
  async function main() {
89
89
  // Use safeJson directly — pulling in queries.js would load all cached state unnecessarily
90
- const config = safeJson(path.join(__dirname, '..', 'config.json')) || {};
90
+ const config = safeJson(path.join(MINIONS_DIR, 'config.json')) || {};
91
91
  const allProjects = getProjects(config).filter(p => p.repoHost === 'ado' || !p.repoHost);
92
92
  const projects = projectName
93
93
  ? allProjects.filter(p => p.name === projectName)
package/engine/cleanup.js CHANGED
@@ -664,7 +664,7 @@ async function runCleanup(config, verbose = false) {
664
664
  const prs = safeJson(projectPrPath(project)) || [];
665
665
  const mergedBranches = new Set();
666
666
  for (const pr of prs) {
667
- if (pr.status === shared.PR_STATUS.MERGED || pr.status === shared.PR_STATUS.ABANDONED || pr.status === shared.PLAN_STATUS.COMPLETED) {
667
+ if (pr.status === shared.PR_STATUS.MERGED || pr.status === shared.PR_STATUS.ABANDONED) {
668
668
  if (pr.branch) mergedBranches.add(pr.branch);
669
669
  }
670
670
  }
@@ -877,7 +877,7 @@ async function runCleanup(config, verbose = false) {
877
877
  const freshMergedBranches = new Set();
878
878
  const freshMergedPrByBranch = new Map();
879
879
  for (const pr of freshPrs) {
880
- if (pr.status === shared.PR_STATUS.MERGED || pr.status === shared.PR_STATUS.ABANDONED || pr.status === shared.PLAN_STATUS.COMPLETED) {
880
+ if (pr.status === shared.PR_STATUS.MERGED || pr.status === shared.PR_STATUS.ABANDONED) {
881
881
  if (pr.branch) freshMergedBranches.add(pr.branch);
882
882
  }
883
883
  if (pr.status === shared.PR_STATUS.MERGED && pr.branch) {
@@ -986,7 +986,7 @@ function cleanDispatchEntries(matchFn) {
986
986
  const safePid = shared.validatePid(pid);
987
987
  if (process.platform === 'win32') {
988
988
  const { execFileSync } = require('child_process');
989
- execFileSync('taskkill', ['/PID', String(safePid), '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
989
+ execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
990
990
  } else {
991
991
  process.kill(safePid, 'SIGTERM');
992
992
  }
package/engine/meeting.js CHANGED
@@ -6,7 +6,7 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
  const shared = require('./shared');
9
- const { safeJson, uid, log, ts, ENGINE_DEFAULTS, WORK_TYPE, DISPATCH_RESULT } = shared;
9
+ const { safeJson, uid, log, ts, ENGINE_DEFAULTS, WORK_TYPE, DISPATCH_RESULT, MEETING_STATUS } = shared;
10
10
  const queries = require('./queries');
11
11
  const { getDispatch, getConfig } = queries;
12
12
  const { renderPlaybook } = require('./playbook');
@@ -829,7 +829,7 @@ function advanceMeetingRound(meetingId) {
829
829
  // already terminal. The authoritative status check still runs INSIDE the
830
830
  // lock below.
831
831
  const existing = getMeeting(meetingId);
832
- if (!existing || existing.status === 'completed' || existing.status === 'archived') return null;
832
+ if (!existing || existing.status === MEETING_STATUS.COMPLETED || existing.status === MEETING_STATUS.ARCHIVED) return null;
833
833
 
834
834
  // CRITICAL: kill BEFORE acquiring the meeting lock. _killMeetingDispatches
835
835
  // takes the dispatch.json lock and shells out to kill processes — never
@@ -838,7 +838,7 @@ function advanceMeetingRound(meetingId) {
838
838
  _killMeetingDispatches(meetingId);
839
839
 
840
840
  return mutateMeeting(meetingId, (meeting) => {
841
- if (!meeting || meeting.status === 'completed' || meeting.status === 'archived') return null;
841
+ if (!meeting || meeting.status === MEETING_STATUS.COMPLETED || meeting.status === MEETING_STATUS.ARCHIVED) return null;
842
842
  if (meeting.status === 'investigating') { meeting.status = 'debating'; meeting.round = 2; }
843
843
  else if (meeting.status === 'debating') { meeting.status = 'concluding'; meeting.round = 3; }
844
844
  else if (meeting.status === 'concluding') { meeting.status = 'completed'; meeting.completedAt = ts(); }
@@ -857,7 +857,7 @@ function endMeeting(meetingId) {
857
857
 
858
858
  return mutateMeeting(meetingId, (meeting) => {
859
859
  if (!meeting) return null;
860
- meeting.status = 'completed';
860
+ meeting.status = MEETING_STATUS.COMPLETED;
861
861
  meeting.completedAt = ts();
862
862
  return meeting;
863
863
  });
@@ -866,7 +866,7 @@ function endMeeting(meetingId) {
866
866
  function archiveMeeting(id) {
867
867
  return mutateMeeting(id, (meeting) => {
868
868
  if (!meeting) return null;
869
- meeting.status = 'archived';
869
+ meeting.status = MEETING_STATUS.ARCHIVED;
870
870
  meeting.archivedAt = ts();
871
871
  return meeting;
872
872
  });
package/engine/queries.js CHANGED
@@ -1717,9 +1717,10 @@ function resetPrdInfoCache() {
1717
1717
  }
1718
1718
 
1719
1719
  // ── Project git status (current branch + dirty/detached) ──────────────────
1720
- // Cached per resolved localPath with a 5-minute TTL. `getProjectGitStatus` is
1721
- // synchronous (returns the cached value or a pending placeholder) and NEVER
1722
- // blocks the event loop the actual git probes run asynchronously via
1720
+ // Cached per resolved localPath with a 15-second TTL (see
1721
+ // `PROJECT_GIT_STATUS_TTL` below). `getProjectGitStatus` is synchronous
1722
+ // (returns the cached value or a pending placeholder) and NEVER blocks the
1723
+ // event loop — the actual git probes run asynchronously via
1723
1724
  // child_process.execFile and write back into the cache when they settle. This
1724
1725
  // is critical for /api/status responsiveness: under the previous synchronous
1725
1726
  // implementation, four projects × three git invocations was ~12 sync shell-outs
@@ -1730,10 +1731,15 @@ function resetPrdInfoCache() {
1730
1731
  // stderr to suppress the `fatal: not a git repository` noise on non-git project
1731
1732
  // paths — same requirement enforced for install/boot paths in
1732
1733
  // test/unit/runtime-fleet-helpers.test.js:465.
1733
- // Single map keyed by normalized localPath. Each entry holds `{ts, value, promise}`:
1734
- // ts: last-settled timestamp (Date.now()), or 0 if never settled
1735
- // value: last-settled status object (or PROJECT_GIT_STATUS_PENDING placeholder)
1736
- // promise: in-flight refresh Promise, or null when idle
1734
+ // Single map keyed by normalized localPath. Each entry holds
1735
+ // `{ts, value, promise, promiseStartedAt, refMtimes}`:
1736
+ // ts: last-settled timestamp (Date.now()), or 0 if never settled
1737
+ // value: last-settled status object (or PROJECT_GIT_STATUS_PENDING placeholder)
1738
+ // promise: in-flight refresh Promise, or null when idle
1739
+ // promiseStartedAt: when the in-flight Promise started (Date.now()), 0 when idle
1740
+ // — used by the stale-pending guard to detect wedged probes
1741
+ // (W-mq19314k00101fc7)
1742
+ // refMtimes: snapshot of HEAD/FETCH_HEAD/refs/remotes mtimes at probe start
1737
1743
  // Folding state + in-flight into one map keeps reads/writes atomic and removes
1738
1744
  // the second-Map sync hazard.
1739
1745
  const _projectGitStatusCache = new Map();
@@ -1752,21 +1758,76 @@ const PROJECT_GIT_STATUS_PENDING = Object.freeze({ gitBranch: null, gitDetached:
1752
1758
  const PROJECT_GIT_STATUS_MISSING = Object.freeze({ gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'missing', remoteDefaultBranch: null, ahead: null, behind: null });
1753
1759
  const PROJECT_GIT_STATUS_NON_GIT = Object.freeze({ gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'non-git', remoteDefaultBranch: null, ahead: null, behind: null });
1754
1760
 
1761
+ // Probe budget constants (W-mq19314k00101fc7). `PROBE_TIMEOUT_MS` is the
1762
+ // per-invocation execFile timeout; `PROBE_WATCHDOG_MS` is an OUTER
1763
+ // belt-and-suspenders rejection that always fires regardless of whether
1764
+ // execFile honored its `timeout` option. Production observation
1765
+ // (2026-06-05): on Windows, execFile's timeout can silently no-op when
1766
+ // Defender/AV stalls the child's I/O, leaving the in-flight Promise
1767
+ // pending indefinitely; every subsequent caller then got the dead Promise
1768
+ // back from the single-flight guard in `_scheduleProjectGitStatusRefresh`
1769
+ // and the cache wedged for minutes. The watchdog converts that hope into
1770
+ // a hard invariant: _gitExec always settles within PROBE_WATCHDOG_MS.
1771
+ const PROBE_TIMEOUT_MS = 10000;
1772
+ const PROBE_WATCHDOG_MS = 15000;
1773
+ let _probeTimeoutOverride = null;
1774
+ let _probeWatchdogOverride = null;
1775
+ // Test seam: shrink the watchdog budget so wedge regression tests can
1776
+ // run in <1s. Returns a restore function that resets to the production
1777
+ // default.
1778
+ function _setProbeWatchdogForTest({ timeoutMs, watchdogMs } = {}) {
1779
+ const prevTimeout = _probeTimeoutOverride;
1780
+ const prevWatchdog = _probeWatchdogOverride;
1781
+ if (typeof timeoutMs === 'number') _probeTimeoutOverride = timeoutMs;
1782
+ if (typeof watchdogMs === 'number') _probeWatchdogOverride = watchdogMs;
1783
+ return () => { _probeTimeoutOverride = prevTimeout; _probeWatchdogOverride = prevWatchdog; };
1784
+ }
1785
+ function _getProbeTimeoutsForTest() {
1786
+ return {
1787
+ timeoutMs: _probeTimeoutOverride != null ? _probeTimeoutOverride : PROBE_TIMEOUT_MS,
1788
+ watchdogMs: _probeWatchdogOverride != null ? _probeWatchdogOverride : PROBE_WATCHDOG_MS,
1789
+ };
1790
+ }
1791
+
1755
1792
  // Async git invocation. Promise-returning so getProjectGitStatus can fire
1756
1793
  // background refreshes without blocking the event loop. Pipes stderr to keep
1757
1794
  // `fatal: not a git repository` from leaking to the dashboard console.
1795
+ //
1796
+ // W-mq19314k00101fc7 — wraps execFile in an outer watchdog Promise.race so
1797
+ // the returned Promise ALWAYS settles within PROBE_WATCHDOG_MS, even if
1798
+ // execFile's own `timeout` option silently no-ops (the Windows production
1799
+ // failure mode). On watchdog fire, we SIGKILL the child explicitly so an
1800
+ // orphaned git.exe doesn't linger.
1758
1801
  function _gitExec(localPath, args) {
1759
1802
  const { execFile } = require('child_process');
1803
+ const timeoutMs = _probeTimeoutOverride != null ? _probeTimeoutOverride : PROBE_TIMEOUT_MS;
1804
+ const watchdogMs = _probeWatchdogOverride != null ? _probeWatchdogOverride : PROBE_WATCHDOG_MS;
1760
1805
  return new Promise((resolve, reject) => {
1761
- execFile('git', ['-C', localPath, ...args], {
1806
+ let settled = false;
1807
+ let watchdogTimer = null;
1808
+ const child = execFile('git', ['-C', localPath, ...args], {
1762
1809
  encoding: 'utf8',
1763
- timeout: 10000,
1810
+ timeout: timeoutMs,
1764
1811
  windowsHide: true,
1765
1812
  stdio: ['pipe', 'pipe', 'pipe'],
1766
1813
  }, (err, stdout) => {
1814
+ if (settled) return;
1815
+ settled = true;
1816
+ if (watchdogTimer) clearTimeout(watchdogTimer);
1767
1817
  if (err) reject(err);
1768
1818
  else resolve(stdout);
1769
1819
  });
1820
+ watchdogTimer = setTimeout(() => {
1821
+ if (settled) return;
1822
+ settled = true;
1823
+ // Belt-and-suspenders: explicitly kill the stuck child so it can't
1824
+ // linger as an orphaned git.exe. SIGKILL on Unix, forced termination
1825
+ // on Windows (Node maps both to TerminateProcess via libuv).
1826
+ try { if (child && typeof child.kill === 'function') child.kill('SIGKILL'); }
1827
+ catch { /* child may already be dead */ }
1828
+ reject(new Error(`git probe watchdog: \`git ${args.join(' ')}\` did not settle within ${watchdogMs}ms`));
1829
+ }, watchdogMs);
1830
+ if (watchdogTimer && typeof watchdogTimer.unref === 'function') watchdogTimer.unref();
1770
1831
  });
1771
1832
  }
1772
1833
 
@@ -1874,8 +1935,26 @@ function _projectGitStatusEqual(a, b) {
1874
1935
 
1875
1936
  function _scheduleProjectGitStatusRefresh(localPath, key, configuredMainBranch) {
1876
1937
  const existing = _projectGitStatusCache.get(key);
1877
- if (existing && existing.promise) return existing.promise;
1878
- const entry = existing || { ts: 0, value: PROJECT_GIT_STATUS_PENDING, promise: null, refMtimes: null };
1938
+ // Stale-pending safety net (W-mq19314k00101fc7 option b). The _gitExec
1939
+ // watchdog (option a) is the primary guarantee that an in-flight probe
1940
+ // ALWAYS settles within PROBE_WATCHDOG_MS. This second-tier check is a
1941
+ // belt-and-suspenders for any future async path that might wedge BEFORE
1942
+ // _gitExec is reached (e.g. fs.existsSync hanging on a disconnected
1943
+ // network drive, or a refactor introducing a pre-gitExec await). If
1944
+ // `entry.promise` is older than 2× the watchdog budget, treat it as
1945
+ // dead and schedule a fresh probe rather than serving the stuck Promise
1946
+ // forever.
1947
+ if (existing && existing.promise) {
1948
+ const watchdogMs = _probeWatchdogOverride != null ? _probeWatchdogOverride : PROBE_WATCHDOG_MS;
1949
+ const startedAt = existing.promiseStartedAt || 0;
1950
+ const age = startedAt ? (Date.now() - startedAt) : 0;
1951
+ if (!startedAt || age < (2 * watchdogMs)) return existing.promise;
1952
+ // Past 2× budget — wedged. Fall through and schedule a fresh probe;
1953
+ // the wedged Promise is intentionally orphaned (its later resolution,
1954
+ // if any, can no longer race the new probe because we overwrite
1955
+ // entry.promise + entry.promiseStartedAt below).
1956
+ }
1957
+ const entry = existing || { ts: 0, value: PROJECT_GIT_STATUS_PENDING, promise: null, promiseStartedAt: 0, refMtimes: null };
1879
1958
  const prevValue = entry.value;
1880
1959
  // Snapshot ref mtimes BEFORE the probe so the next call compares against
1881
1960
  // an exact baseline rather than a Date.now() timestamp. On Windows
@@ -1884,11 +1963,13 @@ function _scheduleProjectGitStatusRefresh(localPath, key, configuredMainBranch)
1884
1963
  // even when nothing actually changed.
1885
1964
  const probeStartTs = Date.now();
1886
1965
  const probeStartRefMtimes = _snapshotProjectGitRefMtimes(localPath, configuredMainBranch);
1966
+ entry.promiseStartedAt = probeStartTs;
1887
1967
  entry.promise = _probeProjectGitStatus(localPath, configuredMainBranch).then(value => {
1888
1968
  entry.ts = probeStartTs;
1889
1969
  entry.refMtimes = probeStartRefMtimes;
1890
1970
  entry.value = value;
1891
1971
  entry.promise = null;
1972
+ entry.promiseStartedAt = 0;
1892
1973
  if (_onProjectGitStatusChanged && !_projectGitStatusEqual(prevValue, value)) {
1893
1974
  try { _onProjectGitStatusChanged(key, value, prevValue); }
1894
1975
  catch { /* hook must never break the probe */ }
@@ -1896,6 +1977,7 @@ function _scheduleProjectGitStatusRefresh(localPath, key, configuredMainBranch)
1896
1977
  return value;
1897
1978
  }, () => {
1898
1979
  entry.promise = null;
1980
+ entry.promiseStartedAt = 0;
1899
1981
  return null;
1900
1982
  });
1901
1983
  _projectGitStatusCache.set(key, entry);
@@ -2109,6 +2191,16 @@ function resetProjectGitStatusCache() {
2109
2191
  _projectGitStatusCache.clear();
2110
2192
  }
2111
2193
 
2194
+ // Test seam (W-mq19314k00101fc7): expose the internal cache Map so the
2195
+ // wedge regression test can inject a synthetic "stuck Promise + stale
2196
+ // promiseStartedAt" entry that mirrors the production failure mode without
2197
+ // having to wait out a real watchdog. Production callers must never read
2198
+ // or mutate this Map directly — always go through getProjectGitStatus /
2199
+ // warmProjectGitStatus.
2200
+ function _getProjectGitStatusCacheForTest() {
2201
+ return _projectGitStatusCache;
2202
+ }
2203
+
2112
2204
  /**
2113
2205
  * Files whose mtime must trigger a dashboard `_fastState` rebuild
2114
2206
  * (W-mpftp7na000td0f4). Single source of truth for the dashboard's
@@ -2378,6 +2470,9 @@ module.exports = {
2378
2470
  warmProjectGitStatus,
2379
2471
  _awaitPendingProjectGitStatusProbes,
2380
2472
  _setOnProjectGitStatusChanged,
2473
+ _setProbeWatchdogForTest,
2474
+ _getProbeTimeoutsForTest,
2475
+ _getProjectGitStatusCacheForTest,
2381
2476
  // W-mpftp7na000td0f4 — engine→dashboard cache-invalidation registry
2382
2477
  getStatusFastStateMtimePaths,
2383
2478
  getStatusSlowStateMtimePaths,
package/engine/shared.js CHANGED
@@ -2218,6 +2218,16 @@ const ENGINE_DEFAULTS = {
2218
2218
  worktreeCreateRetries: 1, // retry once on transient timeout/lock races
2219
2219
  worktreeRoot: '../worktrees',
2220
2220
  worktreeCountCacheTtl: 30000, // 30s — TTL for cached _countWorktrees() result in dashboard
2221
+ // W-mq1habhf: timeout (ms) for the `git status --porcelain` probe inside
2222
+ // engine.js assertCleanSharedWorktree. The default 10s is fine for ordinary
2223
+ // repos but too tight for GVFS/Plastic-backed monorepos where the first
2224
+ // status against a freshly-recreated worktree blocks on sparse hydration.
2225
+ // When the probe times out the engine now quarantines the bad worktree
2226
+ // (status-failed branch) and reports a RETRYABLE failure so the next
2227
+ // dispatch starts on a fresh worktree. Operators on large repos should
2228
+ // raise this knob (e.g. 60000) before tuning anything else. Clamped to
2229
+ // [1000, 120000] in dashboard.js POST /api/settings.
2230
+ assertCleanStatusTimeoutMs: 10000,
2221
2231
  workItemCreateDedupWindowMs: 15 * 60 * 1000, // 15min — collapse duplicate CC/API create races
2222
2232
  idleAlertMinutes: 15,
2223
2233
  fanOutTimeout: null, // falls back to agentTimeout
@@ -3428,7 +3438,7 @@ function queuePlanToPrd({ planFile, prdFile, title, description, project, create
3428
3438
  let item = null;
3429
3439
  mutateJsonFileLocked(centralWiPath, items => {
3430
3440
  if (!Array.isArray(items)) items = [];
3431
- const existing = items.find(w => w.type === 'plan-to-prd' && w.planFile === planFile && (w.status === 'pending' || w.status === 'dispatched'));
3441
+ const existing = items.find(w => w.type === WORK_TYPE.PLAN_TO_PRD && w.planFile === planFile && (w.status === 'pending' || w.status === 'dispatched'));
3432
3442
  if (existing) {
3433
3443
  id = existing.id;
3434
3444
  item = existing;
@@ -3437,7 +3447,7 @@ function queuePlanToPrd({ planFile, prdFile, title, description, project, create
3437
3447
  const newItem = {
3438
3448
  id: 'W-' + uid(),
3439
3449
  title,
3440
- type: 'plan-to-prd',
3450
+ type: WORK_TYPE.PLAN_TO_PRD,
3441
3451
  priority: 'high',
3442
3452
  description,
3443
3453
  status: 'pending',
@@ -5021,7 +5031,40 @@ function extractPrRefFromText(value) {
5021
5031
  return numberMatch ? numberMatch[1] : null;
5022
5032
  }
5023
5033
 
5024
- function extractWorkItemPrRef(item) {
5034
+ // Cap for the description-text fallback scan in `extractWorkItemPrRef`.
5035
+ // Operators pasting a PR pointer put it at the TOP of the description;
5036
+ // long technical descriptions (CI failure reports, blast-radius examples,
5037
+ // 'upstream commit' references) mention OTHER PRs as context deeper in the
5038
+ // body — those mentions must not become the work-item's PR target.
5039
+ // See W-mq17dukn000ef114 (narrowing of W-mpx6i5kh000ac040 / issue #2999).
5040
+ const DESCRIPTION_PR_SCAN_MAX = 500;
5041
+
5042
+ function _descriptionPrScanSlice(description) {
5043
+ const text = String(description || '');
5044
+ if (!text) return '';
5045
+ // First paragraph boundary OR first DESCRIPTION_PR_SCAN_MAX chars,
5046
+ // whichever is shorter. Paragraph split is blank-line (allowing trailing
5047
+ // whitespace on the blank line).
5048
+ const firstParagraph = text.split(/\n\s*\n/, 1)[0];
5049
+ return firstParagraph.slice(0, DESCRIPTION_PR_SCAN_MAX);
5050
+ }
5051
+
5052
+ // Structured-only PR-ref extraction (W-mq18ec6h000p7b87). Walks ONLY the
5053
+ // canonical structured sources — targetPr / pr / pr_id / prId / sourcePr /
5054
+ // pullRequest / prUrl / prNumber, references[*].url, meta.pr_followup
5055
+ // .parent_pr_url. Does NOT scan free-form description/title text. This is
5056
+ // the helper the engine's `pr_not_found` dispatch gate should call: it must
5057
+ // require explicit operator intent (a structured PR pointer) before blocking
5058
+ // dispatch, otherwise commentary like "see PR #3015 for context" in a
5059
+ // description trips the gate and a fresh refactor item with no real PR
5060
+ // target gets stuck in `_pendingReason: 'pr_not_found'` forever.
5061
+ //
5062
+ // Loose-form callers (branch derivation, prompt PR context, dashboard's
5063
+ // create-time stamp) should keep using `extractWorkItemPrRef` below — they
5064
+ // downgrade gracefully when no PR record matches the loose ref, and the
5065
+ // stamp path in particular preserves the operator UX of pasting a PR URL
5066
+ // into the description and getting `targetPr` auto-stamped.
5067
+ function extractStructuredWorkItemPrRef(item) {
5025
5068
  if (!item || typeof item !== 'object') return null;
5026
5069
  const structured = item.targetPr
5027
5070
  || item.pr
@@ -5032,8 +5075,6 @@ function extractWorkItemPrRef(item) {
5032
5075
  || item.prUrl
5033
5076
  || item.prNumber;
5034
5077
  if (structured) return structured;
5035
- // references[*].url — manual /api/work-items callers often supply the PR
5036
- // pointer here when no structured prId/targetPr is known up front.
5037
5078
  if (Array.isArray(item.references)) {
5038
5079
  for (const ref of item.references) {
5039
5080
  const url = ref && typeof ref === 'object' ? ref.url : null;
@@ -5041,14 +5082,26 @@ function extractWorkItemPrRef(item) {
5041
5082
  if (fromUrl) return fromUrl;
5042
5083
  }
5043
5084
  }
5044
- // meta.pr_followup.parent_pr_url — set by playbooks/templates/followup-dispatch.md.
5045
5085
  const followupUrl = item?.meta?.pr_followup?.parent_pr_url;
5046
5086
  if (followupUrl) {
5047
5087
  const fromFollowup = extractPrRefFromText(followupUrl);
5048
5088
  if (fromFollowup) return fromFollowup;
5049
5089
  }
5050
- // Last resort: scan description + title for a PR URL / canonical id.
5051
- const fromDescription = extractPrRefFromText(item.description);
5090
+ return null;
5091
+ }
5092
+
5093
+ function extractWorkItemPrRef(item) {
5094
+ if (!item || typeof item !== 'object') return null;
5095
+ const fromStructured = extractStructuredWorkItemPrRef(item);
5096
+ if (fromStructured) return fromStructured;
5097
+ // Last resort: scan the FIRST PARAGRAPH of description (capped at
5098
+ // DESCRIPTION_PR_SCAN_MAX chars) + title for a PR URL / canonical id.
5099
+ // The narrow slice prevents long `type: fix` descriptions that quote other
5100
+ // PRs as context from false-positiving the fix WI onto the wrong PR
5101
+ // (W-mq17dukn000ef114). This branch is intentionally separate from
5102
+ // extractStructuredWorkItemPrRef so the dispatch gate can require
5103
+ // structured-only intent (W-mq18ec6h000p7b87).
5104
+ const fromDescription = extractPrRefFromText(_descriptionPrScanSlice(item.description));
5052
5105
  if (fromDescription) return fromDescription;
5053
5106
  return extractPrRefFromText(item.title) || null;
5054
5107
  }
@@ -6446,6 +6499,7 @@ module.exports = {
6446
6499
  parsePrUrl, // exported for testing
6447
6500
  extractPrRefFromText,
6448
6501
  extractWorkItemPrRef,
6502
+ extractStructuredWorkItemPrRef,
6449
6503
  getProjectPrScope,
6450
6504
  getPrNumber,
6451
6505
  getPrDisplayId,
package/engine.js CHANGED
@@ -744,18 +744,27 @@ async function _fetchWithTransientRetry(args, opts, label) {
744
744
  return true;
745
745
  } catch (e) {
746
746
  if (!_isTransientGitNetworkError(e)) {
747
- log('warn', `git fetch ${label}: ${String(e.message || e).split('\n')[0]}`);
747
+ // #3045 — log the FULL exec error message (redacted), not just the first
748
+ // line. child_process.exec rejections format as "Command failed: <cmd>"
749
+ // on line 1 and the real stderr ("TF400813 unknown user", "HTTP 401",
750
+ // etc.) on line 2; pre-fix we kept only line 1 via a first-line split,
751
+ // throwing away the actionable stderr and making every swallowed fetch
752
+ // warning look identical, hiding ADO auth failures for the entire #3045
753
+ // incident. _redactBearer keeps any bearer header echoed in the failing
754
+ // git command out of engine logs.
755
+ log('warn', `git fetch ${label}: ${adoGitAuth.redactBearer(String(e.message || e))}`);
748
756
  return false; // swallow non-transient — caller falls back to local ref
749
757
  }
750
- const firstMsg = String(e.message || e).split('\n')[0];
751
- log('warn', `git fetch ${label}: transient (${firstMsg}) — retrying once after 1.5s`);
758
+ const transientMsg = adoGitAuth.redactBearer(String(e.message || e));
759
+ log('warn', `git fetch ${label}: transient (${transientMsg}) — retrying once after 1.5s`);
752
760
  await new Promise(r => setTimeout(r, 1500));
753
761
  try {
754
762
  await _attempt();
755
763
  log('info', `git fetch ${label}: succeeded on retry`);
756
764
  return true;
757
765
  } catch (e2) {
758
- log('warn', `git fetch ${label}: retry also failed (${String(e2.message || e2).split('\n')[0]}) — falling back to local ref`);
766
+ const retryMsg = adoGitAuth.redactBearer(String(e2.message || e2));
767
+ log('warn', `git fetch ${label}: retry also failed (${retryMsg}) — falling back to local ref`);
759
768
  return false;
760
769
  }
761
770
  }
@@ -909,6 +918,14 @@ async function pruneStaleWorktreeForBranch(rootDir, branchName, gitOpts) {
909
918
  // WORKTREE_DIVERGENT reason and retry semantics.
910
919
  async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, dispatchId, gitOpts = {}, opts = {}) {
911
920
  const quarantineOnUnsafe = !!opts.quarantineOnUnsafe;
921
+ // W-mq1habhf: status probe timeout is now configurable so large/GVFS repos
922
+ // can raise it past the 10s default. Clamped to [1000, 120000] to mirror
923
+ // the dashboard validator; mis-set values fall back to the default.
924
+ const statusTimeoutMs = (() => {
925
+ const raw = Number(opts.statusTimeoutMs);
926
+ if (!Number.isFinite(raw) || raw <= 0) return ENGINE_DEFAULTS.assertCleanStatusTimeoutMs;
927
+ return Math.max(1000, Math.min(120000, raw));
928
+ })();
912
929
  const result = {
913
930
  clean: false, healed: false, reason: null, dirtyFiles: [],
914
931
  ahead: 0, behind: 0,
@@ -917,11 +934,56 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
917
934
  // 1. Status probe (filesystem)
918
935
  let statusOut = '';
919
936
  try {
920
- const r = await execAsync('git status --porcelain', { ...gitOpts, cwd: worktreePath, timeout: 10000 });
937
+ const r = await execAsync('git status --porcelain', { ...gitOpts, cwd: worktreePath, timeout: statusTimeoutMs });
921
938
  statusOut = (r || '').toString().trim();
922
939
  } catch (e) {
940
+ // W-mq1habhf: previously this bailed out with the bad worktree intact,
941
+ // and every same-WI retry hit the identical timeout (the .git/worktrees/
942
+ // metadata + branch ref linger from the prior aborted dispatch, so
943
+ // reuse-by-branch keeps re-finding the same broken tree). When the
944
+ // caller opts into quarantineOnUnsafe and no other dispatch claims the
945
+ // branch, we now quarantine the worktree out of the way (rename →
946
+ // worktree prune → reset refs/heads/<branch>) so the next dispatch
947
+ // creates a fresh worktree. The caller emits a RETRYABLE failure
948
+ // (distinct from has-unpushed-commits/no-upstream which preserve
949
+ // potentially-unpushed work and stay non-retryable).
923
950
  result.reason = 'status-failed';
924
951
  result.error = e.message;
952
+ if (quarantineOnUnsafe) {
953
+ const sanitizedB = sanitizeBranch(branchName);
954
+ let otherActive = false;
955
+ try {
956
+ mutateDispatch((dp) => {
957
+ otherActive = (dp.active || []).some(d => {
958
+ if (d.id === dispatchId) return false;
959
+ const dBranch = d.meta?.branch ? sanitizeBranch(d.meta.branch) : '';
960
+ return dBranch === sanitizedB;
961
+ });
962
+ return dp;
963
+ });
964
+ } catch (errOwn) {
965
+ log('warn', `assertCleanSharedWorktree: dispatch read failed during status-failed (${errOwn.message}) — skipping quarantine`);
966
+ otherActive = true;
967
+ }
968
+ if (!otherActive) {
969
+ try {
970
+ const q = await _quarantineDirtyWorktree(
971
+ rootDir, worktreePath, branchName, gitOpts,
972
+ {
973
+ dispatchId,
974
+ reason: result.reason,
975
+ statusError: e.message,
976
+ },
977
+ );
978
+ result.quarantined = true;
979
+ result.quarantinedPath = q.quarantinedPath;
980
+ result.backupRef = q.backupRef;
981
+ } catch (qErr) {
982
+ result.quarantineError = qErr.message;
983
+ log('error', `assertCleanSharedWorktree: quarantine after status-failed failed for ${worktreePath}: ${qErr.message}`);
984
+ }
985
+ }
986
+ }
925
987
  return result;
926
988
  }
927
989
  if (statusOut) {
@@ -937,7 +999,7 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
937
999
  try {
938
1000
  const r = await execAsync(
939
1001
  `git rev-list --left-right --count refs/remotes/origin/${branchName}...HEAD`,
940
- { ...gitOpts, cwd: worktreePath, timeout: 10000 },
1002
+ { ...gitOpts, cwd: worktreePath, timeout: statusTimeoutMs },
941
1003
  );
942
1004
  const parts = String(r || '').trim().split(/\s+/);
943
1005
  const behind = parseInt(parts[0], 10);
@@ -1000,7 +1062,7 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
1000
1062
  try {
1001
1063
  const r = await execAsync(
1002
1064
  `git rev-list ${opts.mainBranch}..HEAD --count`,
1003
- { ...gitOpts, cwd: worktreePath, timeout: 10000 },
1065
+ { ...gitOpts, cwd: worktreePath, timeout: statusTimeoutMs },
1004
1066
  );
1005
1067
  const parsed = parseInt(String(r || '').trim(), 10);
1006
1068
  if (Number.isFinite(parsed)) commitsAheadOfMain = parsed;
@@ -1059,7 +1121,7 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
1059
1121
 
1060
1122
  // 7. Re-verify
1061
1123
  try {
1062
- const r2 = await execAsync('git status --porcelain', { ...gitOpts, cwd: worktreePath, timeout: 10000 });
1124
+ const r2 = await execAsync('git status --porcelain', { ...gitOpts, cwd: worktreePath, timeout: statusTimeoutMs });
1063
1125
  const after = (r2 || '').toString().trim();
1064
1126
  if (after) {
1065
1127
  result.reason = 'dirty-after-clean';
@@ -1853,7 +1915,10 @@ async function spawnAgent(dispatchItem, config) {
1853
1915
  _phaseT.cleanCheckStart = Date.now();
1854
1916
  const cleanResult = await assertCleanSharedWorktree(
1855
1917
  rootDir, worktreePath, branchName, id, _gitOpts,
1856
- { mainBranch: shared.resolveMainBranch(rootDir, project.mainBranch) },
1918
+ {
1919
+ mainBranch: shared.resolveMainBranch(rootDir, project.mainBranch),
1920
+ statusTimeoutMs: engineConfig.assertCleanStatusTimeoutMs ?? ENGINE_DEFAULTS.assertCleanStatusTimeoutMs,
1921
+ },
1857
1922
  );
1858
1923
  _phaseT.cleanCheckEnd = Date.now();
1859
1924
  if (!cleanResult.clean) {
@@ -1912,12 +1977,26 @@ async function spawnAgent(dispatchItem, config) {
1912
1977
  _phaseT.dirtyReusedCheckStart = Date.now();
1913
1978
  const cleanResult = await assertCleanSharedWorktree(
1914
1979
  rootDir, worktreePath, branchName, id, _gitOpts,
1915
- { quarantineOnUnsafe: true, mainBranch: shared.resolveMainBranch(rootDir, project.mainBranch) },
1980
+ {
1981
+ quarantineOnUnsafe: true,
1982
+ mainBranch: shared.resolveMainBranch(rootDir, project.mainBranch),
1983
+ statusTimeoutMs: engineConfig.assertCleanStatusTimeoutMs ?? ENGINE_DEFAULTS.assertCleanStatusTimeoutMs,
1984
+ },
1916
1985
  );
1917
1986
  _phaseT.dirtyReusedCheckEnd = Date.now();
1918
1987
  if (!cleanResult.clean) {
1919
1988
  const previewFiles = (cleanResult.dirtyFiles || []).slice(0, 5).join(', ');
1920
1989
  const isDivergent = (cleanResult.ahead || 0) > 0 || cleanResult.reason === 'no-upstream';
1990
+ // W-mq1habhf: status-failed is a different beast than
1991
+ // has-unpushed-commits / no-upstream. We couldn't even read the
1992
+ // worktree state, so there is nothing to lose by retrying after the
1993
+ // quarantine has cleared the bad tree. Mark this branch retryable
1994
+ // (agentRetryable: true) so the next dispatch creates a fresh
1995
+ // worktree on the same WI without burning a retry slot against the
1996
+ // quarantineAutoRecoveryMax counter. has-unpushed-commits /
1997
+ // no-upstream / dirty-after-clean stay non-retryable because they
1998
+ // protect potentially-unpushed agent work.
1999
+ const isStatusProbeFailed = cleanResult.reason === 'status-failed';
1921
2000
  const failureClassValue = isDivergent ? FAILURE_CLASS.WORKTREE_DIVERGENT : FAILURE_CLASS.WORKTREE_DIRTY;
1922
2001
  const failureClassName = isDivergent ? 'WORKTREE_DIVERGENT' : 'WORKTREE_DIRTY';
1923
2002
  const reasonMsg = cleanResult.quarantined
@@ -1930,7 +2009,7 @@ async function spawnAgent(dispatchItem, config) {
1930
2009
  DISPATCH_RESULT.ERROR,
1931
2010
  reasonMsg.slice(0, 500),
1932
2011
  `Engine preflight refused to dispatch into a dirty/divergent reused worktree (#2996). Reason: ${cleanResult.reason}.${cleanResult.quarantined ? ` Worktree quarantined to ${cleanResult.quarantinedPath}; backup ref ${cleanResult.backupRef || '(skipped)'}. See notes/inbox/ for recovery instructions.` : ''}`,
1933
- { agentRetryable: false, failureClass: failureClassValue },
2012
+ { agentRetryable: isStatusProbeFailed && cleanResult.quarantined, failureClass: failureClassValue },
1934
2013
  );
1935
2014
  cleanupTempAgent(agentId);
1936
2015
  return null;
@@ -5740,9 +5819,27 @@ function getWorkItemPrRef(item) {
5740
5819
  // (issue #2999 / W-mpx6i5kh000ac040). Defence in depth: even if a manual
5741
5820
  // /api/work-items create slipped through without structured PR fields, the
5742
5821
  // engine still detects the PR pointer here at dispatch time.
5822
+ //
5823
+ // NOTE: this loose form is for callers that downgrade gracefully on miss
5824
+ // (resolveWorkItemPrRecord returning null, withWorkItemPrContext skipping
5825
+ // injection, branch derivation falling through to default). The dispatch
5826
+ // *gate* (`pr_not_found`) must NOT use this — it would trip on commentary
5827
+ // like "see PR #3015 for context" in a description with no real PR target.
5828
+ // The gate uses getStructuredWorkItemPrRef below (W-mq18ec6h000p7b87).
5743
5829
  return shared.extractWorkItemPrRef(item);
5744
5830
  }
5745
5831
 
5832
+ function getStructuredWorkItemPrRef(item) {
5833
+ // Structured-only variant used ONLY by the dispatch `pr_not_found` gate
5834
+ // (W-mq18ec6h000p7b87). Walks targetPr/pr/pr_id/prId/sourcePr/pullRequest
5835
+ // /prUrl/prNumber, references[*].url, and meta.pr_followup.parent_pr_url
5836
+ // — never description/title prose. This asymmetry is intentional:
5837
+ // - GATE (here): require explicit operator intent to block dispatch.
5838
+ // - STAMP (dashboard.js#getWorkItemPrRef): keep loose form so operators
5839
+ // pasting PR URLs in description prose get targetPr auto-stamped.
5840
+ return shared.extractStructuredWorkItemPrRef(item);
5841
+ }
5842
+
5746
5843
  function resolveWorkItemPrRecord(item, project) {
5747
5844
  if (!project) return null;
5748
5845
  const prRef = getWorkItemPrRef(item);
@@ -6058,9 +6155,16 @@ function discoverFromWorkItems(config, project) {
6058
6155
  const promptItem = linkedPr ? withWorkItemPrContext(item, linkedPr) : item;
6059
6156
  const prBranch = linkedPr?.branch || '';
6060
6157
  const isPrTargeted = !!(linkedPr && (workType === WORK_TYPE.FIX || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST));
6061
- if (!linkedPr && getWorkItemPrRef(item) && (workType === WORK_TYPE.FIX || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST)) {
6158
+ // W-mq18ec6h000p7b87: gate on the STRUCTURED-only ref. Loose
6159
+ // getWorkItemPrRef would also pick up description-scan refs (e.g. a
6160
+ // refactor item whose prose mentioned "PR #3015 for context") and trip
6161
+ // the gate even though the item has no real PR target. The dashboard's
6162
+ // create-time stamp path keeps using the loose form — gate uses
6163
+ // structured-only; stamp uses loose. See engine/shared.js
6164
+ // extractStructuredWorkItemPrRef for the structured-source list.
6165
+ if (!linkedPr && getStructuredWorkItemPrRef(item) && (workType === WORK_TYPE.FIX || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST)) {
6062
6166
  if (item._pendingReason !== 'pr_not_found') { item._pendingReason = 'pr_not_found'; needsWrite = true; }
6063
- log('warn', `Work item ${item.id} references PR ${getWorkItemPrRef(item)} but no tracked PR record was found`);
6167
+ log('warn', `Work item ${item.id} references PR ${getStructuredWorkItemPrRef(item)} but no tracked PR record was found`);
6064
6168
  continue;
6065
6169
  }
6066
6170
  const isShared = item.branchStrategy === 'shared-branch' && item.featureBranch;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2126",
3
+ "version": "0.1.2128",
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"
@@ -64,7 +64,7 @@ State the size in 3-4 words to yourself, then act:
64
64
 
65
65
  ### Step 2 — Delegate when ≥ Medium (the hard stop)
66
66
  Always delegate these to an agent — do not attempt them yourself even if they look small at first:
67
- - Code changes, fixes, refactors, new features → `POST /api/work-items` with `type: "implement"` or `"fix"`
67
+ - Code changes, fixes, refactors, new features → `POST /api/work-items` with `type: "implement"` (use `"fix"` only when the work targets a specific tracked PR — see the type-selection note under "Calling the Minions API" below)
68
68
  - Exploration, investigation, research, audits → `POST /api/work-items` with `type: "explore"`
69
69
  - Code reviews → `POST /api/work-items` with `type: "review"`
70
70
  - Testing → `POST /api/work-items` with `type: "test"`
@@ -110,12 +110,20 @@ Returns `{routes: [{method, path, description, params}]}`. Use this when you nee
110
110
 
111
111
  **Worked examples** (always include `Content-Type: application/json` on POSTs with a JSON body):
112
112
 
113
- Dispatch a fix:
113
+ Dispatch a fresh bugfix (no tracked PR yet — opens a new PR):
114
114
  ```
115
115
  curl -s -X POST http://localhost:{{dashboard_port}}/api/work-items \
116
116
  -H 'Content-Type: application/json' \
117
117
  -H 'X-CC-Turn-Id: {{cc_turn_id}}' \
118
- -d '{"title":"Fix login bug","type":"fix","project":"MyApp","description":"...","agent":"dallas"}'
118
+ -d '{"title":"Fix login bug","type":"implement","project":"MyApp","description":"...","agent":"dallas"}'
119
+ ```
120
+
121
+ Dispatch a fix against a tracked PR (responds to comments / failing builds on that PR):
122
+ ```
123
+ curl -s -X POST http://localhost:{{dashboard_port}}/api/work-items \
124
+ -H 'Content-Type: application/json' \
125
+ -H 'X-CC-Turn-Id: {{cc_turn_id}}' \
126
+ -d '{"title":"Address review feedback on PR #2533","type":"fix","project":"MyApp","description":"...","references":[{"url":"https://github.com/owner/MyApp/pull/2533","label":"PR #2533"}],"agent":"dallas"}'
119
127
  ```
120
128
 
121
129
  Save a note:
@@ -155,7 +163,7 @@ Set up a "watch + CC triage" follow-up — fire CC headlessly when a watch trips
155
163
  curl -s -X POST http://localhost:{{dashboard_port}}/api/watches \
156
164
  -H 'Content-Type: application/json' \
157
165
  -H 'X-CC-Turn-Id: {{cc_turn_id}}' \
158
- -d '{"target":"W-...","targetType":"work-item","condition":"failed","action":{"type":"cc-triage","params":{"prompt":"WI {{target}} failed. Read the completion report and tail of live-output. If it is a flaky CI failure (transient, unrelated to the diff), file a follow-up `fix` WI titled \"Investigate flake on {{target}}\". Otherwise post an inbox note explaining the root cause and do nothing else.","attachArtifacts":["completion-report","live-output"]}}}'
166
+ -d '{"target":"W-...","targetType":"work-item","condition":"failed","action":{"type":"cc-triage","params":{"prompt":"WI {{target}} failed. Read the completion report and tail of live-output. If it is a flaky CI failure (transient, unrelated to the diff), file a follow-up `implement` WI titled \"Investigate flake on {{target}}\" (no tracked PR yet — fresh bugfix opens a new PR, so type=implement, not fix). Otherwise post an inbox note explaining the root cause and do nothing else.","attachArtifacts":["completion-report","live-output"]}}}'
159
167
  ```
160
168
  The `cc-triage` action is internal — it runs in an isolated CC session (not your interactive ccSession) and never shows up in the user's tab list. Use it when "fire a single fixed work item" isn't expressive enough.
161
169
 
@@ -227,6 +235,7 @@ curl -s -X POST http://localhost:{{dashboard_port}}/api/qa/session \
227
235
 
228
236
  **Required fields per endpoint** — the server returns `{ error: "..." }` if missing. Common cases:
229
237
  - `POST /api/work-items`: `title` REQUIRED. `description` recommended. `project` REQUIRED when multiple projects are configured (server returns the list of known names if you guess wrong). `type` defaults to `implement`; valid values: `fix`, `implement`, `implement:large`, `setup`, `explore`, `ask`, `review`, `test`, `verify`. Agent hint via `agent` (string) or `agents` (array).
238
+ - **`fix` vs `implement` — pick deliberately.** `type: "fix"` in the engine means "this work item responds to comments or build failures on a tracked PR" — it requires a PR pointer (in `references[*].url`, `meta.pr_followup.parent_pr_url`, or `targetPr`/`pr_id`/`prNumber`) and is dispatched onto the PR's source branch. **Fresh bugfix work** that will open a NEW PR (engine bugs, default-branch CI failures, repo-level regressions found from logs) is `type: "implement"`, not `type: "fix"`. Emitting `type: "fix"` without a PR pointer trips the `pr_not_found` discovery gate (engine.js#discoverWork) and the WI stays blocked indefinitely. Rule of thumb: if the work could land on master via a brand-new PR, it's `implement`; if it must push commits into an existing PR's source branch, it's `fix`.
230
239
  - Exempt from the `project` requirement (these run rootless or via central paths): `ask`, `explore`, `plan`, `plan-to-prd`, `meeting`. (`docs` is intentionally NOT exempt — it's write-capable and lands in `WORKTREE_REQUIRING_TYPES`, so it needs a real project worktree. For minions-repo docs work, pass `project: "minions"` explicitly.) `setup` is also in the project-required set — it operates inside a real project worktree but produces no PR. Every other type needs a project worktree, so the server rejects project-less creates with `400 { error, knownProjects }` when ≠1 project is configured.
231
240
  - **`meta.keep_processes: true`** — opt-in flag that lets the agent leave specific descendant PIDs running after it exits (default: engine reaps EVERYTHING the agent spawned). **Set this whenever the user's intent is to leave a process alive after the agent finishes** — e.g. "spin up the dev server and exit", "start the watcher and leave it running", "set up my dev env", "keep the emulator open", "launch the daemon for me", "boot the constellation host and disconnect". Don't set it for normal build/test/run-once tasks (`npm test`, `npm run build`, one-shot scripts) — those should be reaped. Also accepts optional `meta.keep_processes_ttl_minutes` (default 60, hard-cap 1440 = 24h). When you set this flag, also make the WI title/description say something like "leave the dev server running" so the agent knows to write `agents/<id>/keep-pids.json` before exiting (the playbook injects the contract automatically when the flag is on). Example: `-d '{"title":"Spin up Constellation dev env and leave server running","type":"implement","project":"constellation","description":"Run bun install + bun run dev. Leave the dev server (port 5173) and Constellation host (port 3001) running after you exit so the user can iterate.","meta":{"keep_processes":true,"keep_processes_ttl_minutes":240}}'`. Inspect / kill kept PIDs anytime via `GET /api/keep-processes` and `POST /api/keep-processes/kill`.
232
241
  - **`skipPr: true`** — opt-in flag that tells the engine NOT to enforce the PR-attachment contract for this work item, so the WI can complete `done` without the missing-PR hard-fail. **Set this when the dispatch mutates state OUTSIDE any tracked git repo and therefore cannot produce a PR** — e.g. cleaning `~/.claude/skills/`, editing runtime config under `~/.config/`, resetting the dashboard cache, mutating engine JSON state files (`engine/*.json`) the engine itself owns, or local tooling installs. **Do NOT set it for any task that touches a tracked repo's source** — even one-line diffs in a real repo should produce a PR. Type-selection rule of thumb: prefer `type: "setup"` for infra/dev-env bootstrap tasks that mutate project state but produce no PR (it's implicitly PR-exempt — no `skipPr` needed); prefer `type: "explore"` for genuinely read-only tasks (rootless, no worktree, no PR contract); use `skipPr: true` only when the task is write-side mutation but the writes don't land in a git repo and `setup` doesn't fit (e.g. cleaning user-machine state outside any repo). Example: `-d '{"title":"Bootstrap Constellation dev stack","type":"setup","project":"constellation","description":"Run bun install + bun run dev and leave the dev server running.","meta":{"managed_spawn":true}}'`.
@@ -38,18 +38,27 @@ For explicit dispatch/delegation requests or medium/larger work without a direct
38
38
 
39
39
  Always include the `X-CC-Turn-Id: {{cc_turn_id}}` header on state-changing calls so the dashboard can correlate the work item with this conversation turn and surface a confirmation chip in your reply.
40
40
 
41
- Worked example for a dispatch:
41
+ Worked example for a fresh-bugfix dispatch (no tracked PR yet — opens a new PR):
42
42
 
43
43
  ```
44
44
  curl -s -X POST http://localhost:{{dashboard_port}}/api/work-items \
45
45
  -H 'Content-Type: application/json' \
46
46
  -H 'X-CC-Turn-Id: {{cc_turn_id}}' \
47
- -d '{"title":"Fix login bug","type":"fix","project":"MyApp","description":"...","agent":"dallas"}'
47
+ -d '{"title":"Fix login bug","type":"implement","project":"MyApp","description":"...","agent":"dallas"}'
48
+ ```
49
+
50
+ Worked example for a PR-tied fix (responds to comments / failing builds on an existing tracked PR):
51
+
52
+ ```
53
+ curl -s -X POST http://localhost:{{dashboard_port}}/api/work-items \
54
+ -H 'Content-Type: application/json' \
55
+ -H 'X-CC-Turn-Id: {{cc_turn_id}}' \
56
+ -d '{"title":"Address review feedback on PR #2533","type":"fix","project":"MyApp","description":"...","references":[{"url":"https://github.com/owner/MyApp/pull/2533","label":"PR #2533"}],"agent":"dallas"}'
48
57
  ```
49
58
 
50
59
  Body fields:
51
60
  - `title` REQUIRED.
52
- - `type` REQUIRED. Use `explore` for audits/investigations/research/architecture analysis/substantial queries needing a written report. Use `ask` for substantial but non-investigative answer-only requests. Use `implement`/`fix`/`review`/`test`/`verify` when the requested outcome matches those engine flows.
61
+ - `type` REQUIRED. Use `explore` for audits/investigations/research/architecture analysis/substantial queries needing a written report. Use `ask` for substantial but non-investigative answer-only requests. Use `implement`/`fix`/`review`/`test`/`verify` when the requested outcome matches those engine flows. **`fix` vs `implement`:** `type: "fix"` is reserved for work targeting a specific tracked PR (responds to comments / build failures on that PR) — it requires a PR pointer (in `references[*].url` or `targetPr`/`pr_id`/`prNumber`) and dispatches onto the PR's source branch. **Fresh bugfix work** that will open a NEW PR (engine bugs, CI failures on the default branch, repo-level regressions) uses `type: "implement"`. Emitting `type: "fix"` without a tracked PR pointer blocks the WI at the engine's `pr_not_found` discovery gate.
53
62
  - `priority` optional: `low`/`medium`/`high`.
54
63
  - `description` recommended.
55
64
  - `project` REQUIRED when multiple projects are configured.