@yemi33/minions 0.1.2227 → 0.1.2229

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/bin/minions.js CHANGED
@@ -1168,6 +1168,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1168
1168
  minions cleanup Clean temp files, worktrees, zombies
1169
1169
  minions pr comment <repo> <n> Post a marker-prepended PR comment via gh
1170
1170
  --agent <id> --kind <k> [--wi <id>] (--body-file <f> | --body <text>)
1171
+ [--harness-file <f> | --harness-json <j>] folds in a "Harnesses used" section
1171
1172
  minions nuke --confirm Factory reset (delete state, reset config to defaults)
1172
1173
  minions uninstall --confirm Remove everything + uninstall npm package
1173
1174
 
@@ -937,7 +937,7 @@ function _wiRenderDetail(item) {
937
937
  });
938
938
  if (!pills) return;
939
939
  var legend = anyUngrounded
940
- ? '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:4px">&#x26A0; dashed = self-reported but not grounded against what the engine exposed</div>'
940
+ ? '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:4px">&#x26A0; dashed = agent-reported, not verified by the engine</div>'
941
941
  : '';
942
942
  html += field('Repo harnesses used', '<div style="display:flex;flex-wrap:wrap;gap:4px">' + pills + '</div>' + legend);
943
943
  })();
package/dashboard.js CHANGED
@@ -14214,6 +14214,23 @@ if (require.main === module) {
14214
14214
  const { execSync } = require('child_process');
14215
14215
  setInterval(() => {
14216
14216
  try {
14217
+ // Respect `minions stop` / `minions uninstall` / mid-restart windows.
14218
+ // This is the third engine respawner (alongside engine/supervisor.js and
14219
+ // engine/watchdog.js) and must honor stop-intent too, or it would
14220
+ // resurrect the engine every 30s when stop-intent is set but
14221
+ // control.state is still 'running' (crash mid-stop, or stop-intent set
14222
+ // without a control-state flip). Fail-open: if isStopIntentSet is absent
14223
+ // (forks without a stop-intent producer), proceed with recovery —
14224
+ // matching engine/watchdog.js.
14225
+ let stopWanted = false;
14226
+ if (typeof shared.isStopIntentSet === 'function') {
14227
+ try { stopWanted = !!shared.isStopIntentSet(); } catch { stopWanted = false; }
14228
+ }
14229
+ if (stopWanted) {
14230
+ console.log(`[watchdog] stop-intent set — standing down (no restart)`);
14231
+ return;
14232
+ }
14233
+
14217
14234
  const control = getEngineState();
14218
14235
  if (control.state !== 'running' || !control.pid) return;
14219
14236
 
@@ -14222,7 +14239,10 @@ if (require.main === module) {
14222
14239
  try {
14223
14240
  if (process.platform === 'win32') {
14224
14241
  const out = execSync(`tasklist /FI "PID eq ${control.pid}" /NH`, { encoding: 'utf8', timeout: 3000, windowsHide: true });
14225
- alive = out.includes(String(control.pid));
14242
+ // Word-boundary + image-name match (mirrors engine/restart-health.js)
14243
+ // so a digit-substring collision in another column can't read a dead
14244
+ // engine pid as alive and suppress a needed respawn.
14245
+ alive = shared.tasklistOutputShowsPid(out, control.pid, { imageName: 'node' });
14226
14246
  } else {
14227
14247
  process.kill(control.pid, 0); // signal 0 = check existence
14228
14248
  alive = true;
@@ -110,11 +110,15 @@ evaluation pass) can see what tooling drove a dispatch:
110
110
  and ⚠️-marked), and returns `''` for an empty/absent record. The GitHub
111
111
  comment path folds it in via `engine/gh-comment.js#buildMinionsCommentBody`
112
112
  (optional `harnessUsed` arg, threaded through `postPrComment` /
113
- `postPrReviewComment` / `postPrReview`); the Azure DevOps path has no
114
- engine-side comment poster (agents post via `az repos pr comment` / REST),
115
- so review/fix agents append the **byte-identical** section themselves per
116
- `playbooks/shared-rules.md` "Harness transparency / self-report". Both
117
- surfaces consume the one renderer, so there is no second formatter to drift.
113
+ `postPrReviewComment` / `postPrReview`); the Azure DevOps path mirrors it via
114
+ `engine/ado-comment.js#postAdoPrComment` (same builder, same `harnessUsed`
115
+ arg). Both posters are reached from the `minions pr comment` CLI, which turns
116
+ `--harness-file` / `--harness-json` into the grounded record and folds the
117
+ **byte-identical** section in for the agent. Only the raw `gh pr comment` /
118
+ `az repos pr comment` / REST fallbacks bypass that chokepoint, so on those
119
+ paths review/fix agents append the section themselves per
120
+ `playbooks/shared-rules.md` → "Harness transparency / self-report". Every
121
+ surface consumes the one renderer, so there is no second formatter to drift.
118
122
  2. **notes/inbox digest** — harness usage is summarized into the learnings /
119
123
  inbox stream that feeds consolidation, so cross-task patterns ("everyone
120
124
  reaches for skill X on Android fixes") become visible to the team-memory
@@ -47,6 +47,34 @@ recycle worktree dirs across branches.
47
47
  → `git checkout --detach origin/<main>` → mark IDLE.
48
48
  - **State** at `engine/worktree-pool.json`; git ops outside any lock.
49
49
 
50
+ ## Ownership marker is never "dirty" (#284)
51
+
52
+ The reused-worktree preflight (`assertCleanSharedWorktree`) builds its
53
+ dirty-file list from `git status --porcelain`. The engine's own
54
+ ownership marker `.minions-worktree` (`shared.WORKTREE_OWNER_MARKER`) is
55
+ gitignored in the repo's *current* tree, but a reused worktree that
56
+ checks out a branch whose tree predates that `.gitignore` line (or a
57
+ foreign repo) surfaces the marker as an untracked `?? .minions-worktree`
58
+ entry. Counting that engine-stamped artifact as filesystem-dirt used to
59
+ flip an otherwise-safe-to-reuse worktree (no upstream, HEAD at main tip)
60
+ into the conservative quarantine path → a non-retryable `WORKTREE_DIRTY`
61
+ failure for the whole work item.
62
+
63
+ `shared.isWorktreeOwnerMarkerStatusLine(porcelainLine)` recognizes a
64
+ porcelain line that refers ONLY to the root-level marker; the preflight
65
+ filters it out of `dirtyFiles` at every status read (initial probe,
66
+ post-reset re-verify) and the dirty-files prompt-injection site
67
+ (`engine.js`). **An untracked marker alone is a CLEAN tree for reuse.**
68
+ A same-named file nested in a subdir is NOT the marker and stays dirty;
69
+ the marker filter never masks a genuine user/agent edit beside it.
70
+
71
+ When the dirt is real and the worktree can't be auto-healed, the engine
72
+ still quarantines and fails non-retryably, and the quarantine
73
+ auto-recovery loop re-queues the item once in a fresh worktree (see
74
+ **Auto-recovery cap** below). The spawn-error completion report
75
+ distinguishes the **original worktree-preflight issue** from a
76
+ **retry-in-fresh-worktree** failure via `_quarantineRecoveryCount`.
77
+
50
78
  ## Quarantine path (dirty / divergent)
51
79
 
52
80
  When `discoverFromWorkItems` finds a worktree in a `WORKTREE_DIRTY`,
package/engine/cli.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 { safeRead, safeJson, safeWrite, mutateControl, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT } = shared;
9
+ const { safeRead, safeJson, safeJsonArr, safeWrite, mutateControl, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT } = shared;
10
10
  const queries = require('./queries');
11
11
  const { getConfig, getControl, getDispatch, getAgentStatus,
12
12
  MINIONS_DIR, ENGINE_DIR, AGENTS_DIR, PLANS_DIR, PRD_DIR, CONTROL_PATH, DISPATCH_PATH } = queries;
@@ -250,7 +250,7 @@ const CLI_COMMAND_DOCS = Object.freeze({
250
250
  'mcp-sync': { args: '', summary: 'Print harness propagation diagnostic (same source as `minions doctor --harness`; read-only, no writes)' },
251
251
  doctor: { args: '[--harness]', summary: 'Check prerequisites and runtime health (--harness: print harness propagation diagnostic)' },
252
252
  config: { args: 'set-cli <R> [--model M]', summary: 'Persist defaultCli/defaultModel without starting' },
253
- pr: { args: 'comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>] [--body-file <f>|--body <text>]', summary: 'Post a marker-prepended PR comment via gh' },
253
+ pr: { args: 'comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>] [--harness-file <f>|--harness-json <j>] [--body-file <f>|--body <text>]', summary: 'Post a marker-prepended PR comment via gh' },
254
254
  bridge: { args: 'status|health|enable|disable', summary: 'Constellation bridge: toggle and inspect the read-only cross-repo feed' },
255
255
  });
256
256
 
@@ -413,6 +413,68 @@ function _applyRuntimeFlags({ cli, model, modelExplicit }) {
413
413
  return { warnings, applied: true };
414
414
  }
415
415
 
416
+ // resolveHarnessUsedForComment(flags) — P-7a3c9e21. Turn the `minions pr comment`
417
+ // harness flags into a grounded `harnessUsed` record suitable for the posters'
418
+ // `harnessUsed` param (engine/gh-comment.js / engine/ado-comment.js), which fold
419
+ // it via buildMinionsCommentBody -> buildHarnessUsedSection.
420
+ //
421
+ // Source precedence: `--harness-file <path>` (JSON on disk) wins over the inline
422
+ // `--harness-json <json>` convenience; neither supplied -> returns undefined so
423
+ // the comment body is byte-identical to today (no section). Read/parse failures
424
+ // are HARD errors (process.exit(2) with the offending message) — never a silent
425
+ // drop. Grounding uses the spawn-time `_harnessPropagated` manifest located by
426
+ // dispatch id (explicit `--dispatch-id` -> else basename(MINIONS_COMPLETION_REPORT,
427
+ // '.json')); an unresolvable manifest passes null, marking every entry
428
+ // grounded:false — the honest "no record" state, not a fatal condition.
429
+ function resolveHarnessUsedForComment(flags) {
430
+ const fileArg = flags['harness-file'];
431
+ const jsonArg = flags['harness-json'];
432
+
433
+ let raw;
434
+ if (fileArg !== undefined) {
435
+ try {
436
+ raw = fs.readFileSync(fileArg, 'utf8');
437
+ } catch (e) {
438
+ console.error(`error: could not read --harness-file ${fileArg}: ${e.message}`);
439
+ process.exit(2);
440
+ }
441
+ } else if (jsonArg !== undefined) {
442
+ raw = String(jsonArg);
443
+ } else {
444
+ return undefined;
445
+ }
446
+
447
+ let parsed;
448
+ try {
449
+ parsed = JSON.parse(raw);
450
+ } catch (e) {
451
+ const src = fileArg !== undefined ? `--harness-file ${fileArg}` : '--harness-json';
452
+ console.error(`error: invalid JSON in ${src}: ${e.message}`);
453
+ process.exit(2);
454
+ }
455
+
456
+ // Resolve the grounding manifest best-effort: explicit id -> completion-report
457
+ // basename. A missing/unresolvable manifest is non-fatal (groundHarnessUsed
458
+ // with null propagated marks everything grounded:false).
459
+ const dispatchId = flags['dispatch-id']
460
+ || path.basename(process.env.MINIONS_COMPLETION_REPORT || '', '.json')
461
+ || undefined;
462
+ let propagated = null;
463
+ if (dispatchId) {
464
+ try {
465
+ const dispatch = getDispatch();
466
+ for (const queue of ['active', 'completed', 'pending']) {
467
+ const list = Array.isArray(dispatch?.[queue]) ? dispatch[queue] : null;
468
+ if (!list) continue;
469
+ const found = list.find(d => d && d.id === dispatchId);
470
+ if (found && found._harnessPropagated) { propagated = found._harnessPropagated; break; }
471
+ }
472
+ } catch { propagated = null; }
473
+ }
474
+
475
+ return shared.groundHarnessUsed(parsed, propagated);
476
+ }
477
+
416
478
  const commands = {
417
479
  start(...startArgs) {
418
480
  // Apply --cli / --model fleet flags before any engine wiring touches
@@ -782,7 +844,7 @@ const commands = {
782
844
  const projName = item.meta.project?.name;
783
845
  if (projName) {
784
846
  const prPath = path.join(MINIONS_DIR, 'projects', projName, 'pull-requests.json');
785
- const prs = safeJson(prPath) || [];
847
+ const prs = safeJsonArr(prPath);
786
848
  const matchingPr = prs.find(pr =>
787
849
  (pr.prdItems || []).includes(item.meta.item.id) &&
788
850
  pr.status !== 'abandoned' && pr.status !== 'closed'
@@ -1760,13 +1822,13 @@ const commands = {
1760
1822
  }
1761
1823
  }
1762
1824
  if (exists && name === 'pullRequests') {
1763
- const prs = safeJson(filePath) || [];
1825
+ const prs = safeJsonArr(filePath);
1764
1826
  const pending = prs.filter(p => p.status === PR_STATUS.ACTIVE && (p.reviewStatus === REVIEW_STATUS.PENDING || p.reviewStatus === REVIEW_STATUS.WAITING));
1765
1827
  const needsFix = prs.filter(p => p.status === PR_STATUS.ACTIVE && p.reviewStatus === REVIEW_STATUS.CHANGES_REQUESTED);
1766
1828
  console.log(` PRs: ${pending.length} pending review, ${needsFix.length} need fixes`);
1767
1829
  }
1768
1830
  if (exists && name === 'workItems') {
1769
- const items = safeJson(filePath) || [];
1831
+ const items = safeJsonArr(filePath);
1770
1832
  const queued = items.filter(i => i.status === WI_STATUS.QUEUED);
1771
1833
  console.log(` Items: ${queued.length} queued`);
1772
1834
  }
@@ -1982,6 +2044,11 @@ const commands = {
1982
2044
  console.log(' GitHub: minions pr comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>] (--body-file <path> | --body <text>)');
1983
2045
  console.log(' ADO: minions pr comment <prNumber> --host ado --ado-org <org> --ado-project <proj> --repo-id <id> --agent <id> --kind <k> [--wi <id>] (--body-file <path> | --body <text>)');
1984
2046
  console.log('');
2047
+ console.log('Optional harness self-report (folded into the collapsible "Harnesses used" section):');
2048
+ console.log(' --harness-file <path> JSON file with the { skills, mcpServers, commands, docs } record (wins over --harness-json)');
2049
+ console.log(' --harness-json <json> same record inline as a JSON string');
2050
+ console.log(' --dispatch-id <id> grounding manifest override (else basename of $MINIONS_COMPLETION_REPORT)');
2051
+ console.log('');
1985
2052
  console.log('Posts a PR comment with the hidden minions marker, the collapsible');
1986
2053
  console.log('"Harnesses used" section, and the brand link folded in by the shared');
1987
2054
  console.log('builder — so engine classifiers identify agent-authored comments by');
@@ -2040,6 +2107,12 @@ const commands = {
2040
2107
  process.exit(2);
2041
2108
  }
2042
2109
 
2110
+ // P-7a3c9e21 — thread the consulted-sources footprint into both posters so
2111
+ // the auto-folded "Harnesses used" <details> section the help already
2112
+ // promises is actually rendered. Resolved once (host-neutral); absent flags
2113
+ // -> undefined -> body byte-identical to today.
2114
+ const harnessUsed = resolveHarnessUsedForComment(flags);
2115
+
2043
2116
  // ── Azure DevOps: <prNumber> --host ado --ado-org --ado-project --repo-id ──
2044
2117
  if (isAdo) {
2045
2118
  const [prNumberRaw] = positional;
@@ -2059,7 +2132,7 @@ const commands = {
2059
2132
  const orgBase = flags['org-base'] || `https://dev.azure.com/${adoOrg}`;
2060
2133
  const adoComment = require('./ado-comment');
2061
2134
  adoComment.postAdoPrComment({
2062
- orgBase, project, repositoryId, prNumber, body, agentId, kind, workItemId,
2135
+ orgBase, project, repositoryId, prNumber, body, agentId, kind, workItemId, harnessUsed,
2063
2136
  }).then((result) => {
2064
2137
  if (result && result.threadId) console.log(`ADO thread ${result.threadId} created`);
2065
2138
  }).catch((e) => {
@@ -2084,7 +2157,7 @@ const commands = {
2084
2157
 
2085
2158
  try {
2086
2159
  const result = ghComment.postPrComment({
2087
- repo, prNumber, body, agentId, kind, workItemId,
2160
+ repo, prNumber, body, agentId, kind, workItemId, harnessUsed,
2088
2161
  });
2089
2162
  if (result.output) console.log(result.output);
2090
2163
  } catch (e) {
@@ -2223,6 +2296,8 @@ module.exports = {
2223
2296
  _readDispatchPid: readDispatchPid,
2224
2297
  _normalizeSessionBranch: normalizeSessionBranch,
2225
2298
  _dispatchSessionBranch: dispatchSessionBranch,
2299
+ // P-7a3c9e21 — harness-flag resolver exported for CLI-threading tests.
2300
+ _resolveHarnessUsedForComment: resolveHarnessUsedForComment,
2226
2301
  // W-mpcyvff6000pf828 (#2653) — heartbeat writer + factory exported for tests
2227
2302
  _writeHeartbeatNow: writeHeartbeatNow,
2228
2303
  _createHeartbeatInterval: createHeartbeatInterval,
@@ -112,7 +112,7 @@ function buildHarnessUsedSection(harnessUsed) {
112
112
  if (lines.length === 0) return '';
113
113
 
114
114
  const legend = anyUngrounded
115
- ? `\n\n> ${HARNESS_WARN_ICON} self-reported but not grounded against what the engine exposed.`
115
+ ? `\n\n> ${HARNESS_WARN_ICON} agent-reported, not verified by the engine`
116
116
  : '';
117
117
 
118
118
  return `<details>\n<summary>${HARNESS_SUMMARY_ICON} Harnesses used (${lines.length})</summary>\n\n`
@@ -21,7 +21,7 @@
21
21
  const { execFileSync } = require('child_process');
22
22
  const path = require('path');
23
23
  const shared = require('./shared');
24
- const { safeJson, MINIONS_DIR, log } = shared;
24
+ const { safeJsonObj, MINIONS_DIR, log } = shared;
25
25
 
26
26
  const TOKEN_TTL_MS = 30 * 60 * 1000; // 30 minutes
27
27
  const FETCH_TIMEOUT_MS = 10000; // 10s — same ceiling as `gh api user`
@@ -40,7 +40,7 @@ function _readConfig(opts = {}) {
40
40
  return _cachedConfig;
41
41
  }
42
42
  const configPath = path.join(MINIONS_DIR, 'config.json');
43
- _cachedConfig = safeJson(configPath) || {};
43
+ _cachedConfig = safeJsonObj(configPath);
44
44
  _cachedConfigAt = Date.now();
45
45
  return _cachedConfig;
46
46
  }
package/engine/github.js CHANGED
@@ -633,11 +633,11 @@ async function forEachActiveGhPr(config, callback) {
633
633
  if (updated) {
634
634
  // Also update title/author/branch if still placeholder
635
635
  const currentTitle = pr.title || '';
636
- if (!currentTitle || currentTitle.includes('polling...') || pr.agent === 'human' || pr.description === undefined) {
636
+ if (shared.isPlaceholderPrTitle(currentTitle) || pr.agent === 'human' || pr.description === undefined) {
637
637
  const prData = await ghApi(`/pulls/${prNum}`, slug);
638
638
  if (prData) {
639
639
  const latestTitle = pr.title || '';
640
- if (!latestTitle || latestTitle.includes('polling...') || /[{}"\[\]]/.test(latestTitle) || /^[0-9a-f-]{8,}$/i.test(latestTitle)) {
640
+ if (shared.isPlaceholderPrTitle(latestTitle)) {
641
641
  pr.title = (prData.title || latestTitle).slice(0, 120);
642
642
  }
643
643
  if (pr.description === undefined) pr.description = (prData.body || '').slice(0, 500);
@@ -752,10 +752,7 @@ async function pollPrStatus(config) {
752
752
  // stay stuck on "PR #N (polling...)" forever. `prData` is already in
753
753
  // hand from line 622 — no extra API call needed.
754
754
  const currentTitleForBackfill = pr.title || '';
755
- if (!currentTitleForBackfill
756
- || currentTitleForBackfill.includes('polling...')
757
- || /[{}"\[\]]/.test(currentTitleForBackfill)
758
- || /^[0-9a-f-]{8,}$/i.test(currentTitleForBackfill)) {
755
+ if (shared.isPlaceholderPrTitle(currentTitleForBackfill)) {
759
756
  if (prData.title) {
760
757
  const nextTitle = String(prData.title).slice(0, 120);
761
758
  if (pr.title !== nextTitle) {
@@ -32,9 +32,6 @@ function checkPlanCompletion(meta, config) {
32
32
  // terminal artifacts; missing primary means "gone", not "needs recovery".
33
33
  const plan = safeJsonNoRestore(planPath);
34
34
  if (!plan?.missing_features) return;
35
- if (plan.status === PLAN_STATUS.COMPLETED) {
36
- if (plan._completionNotified) return;
37
- }
38
35
 
39
36
  const projects = shared.getProjects(config);
40
37
 
@@ -43,6 +40,21 @@ function checkPlanCompletion(meta, config) {
43
40
  const planItems = allWorkItems.filter(w => w.sourcePlan === planFile && w.itemType !== 'pr' && w.itemType !== 'verify');
44
41
  if (planItems.length === 0) return;
45
42
 
43
+ // W-mqk5ld3p: verify-creation must be a pure function of work-item state,
44
+ // independent of who/what set `status: completed`. We do NOT short-circuit on
45
+ // the raw `_completionNotified` boolean — a PRD can land on disk pre-completed
46
+ // with the flag already set (out-of-band write by the plan-to-prd / pipeline
47
+ // path), and the legacy top-of-function `if (_completionNotified) return` then
48
+ // permanently skipped the aggregate build/test verify gate. Instead the flag
49
+ // gates ONLY the one-shot completion summary (below); control always falls
50
+ // through to the verify-creation block, which re-checks for an existing verify
51
+ // WI under the file lock and is therefore safe to reach every scan. The one
52
+ // exception is the REOPEN sub-path (terminal verify → re-open): that is a
53
+ // plan-modification concern (the dashboard clears `_completionNotified` when it
54
+ // re-opens a plan), so it is gated on `!alreadyNotified` to avoid bouncing an
55
+ // already-done verify on every steady-state scan.
56
+ const alreadyNotified = plan.status === PLAN_STATUS.COMPLETED && !!plan._completionNotified;
57
+
46
58
  // Hard completion gate: every PRD feature ID must have a corresponding work item in a terminal state.
47
59
  const planFeatureIds = new Set((plan.missing_features || []).map(f => f.id).filter(Boolean));
48
60
  const workItemById = {};
@@ -146,18 +158,24 @@ function checkPlanCompletion(meta, config) {
146
158
  ...uniquePrs.map(pr => `- ${pr.id}: ${pr.title || ''} ${pr.url || ''}`),
147
159
  ].filter(Boolean).join('\n');
148
160
 
149
- // Write summary to notes/inbox
150
- const summarySlug = `prd-completion-${planFile.replace('.json', '')}`;
151
- shared.writeToInbox('engine', summarySlug, summary);
152
- log('info', `PRD completion summary written to notes/inbox/${summarySlug}`);
153
-
154
- // Persist completed status + _completionNotified via file lock
155
- mutateJsonFileLocked(planPath, (data) => {
156
- data.status = PLAN_STATUS.COMPLETED;
157
- data.completedAt = plan.completedAt;
158
- data._completionNotified = true;
159
- return data;
160
- });
161
+ // Write summary to notes/inbox + flip _completionNotified — one-shot side
162
+ // effects, skipped for a pre-completed PRD that already carried the flag
163
+ // (alreadyNotified). The verify-creation block below still runs and is
164
+ // idempotent, so the plan still gets its verify gate without a duplicate
165
+ // summary or a redundant flag write.
166
+ if (!alreadyNotified) {
167
+ const summarySlug = `prd-completion-${planFile.replace('.json', '')}`;
168
+ shared.writeToInbox('engine', summarySlug, summary);
169
+ log('info', `PRD completion summary written to notes/inbox/${summarySlug}`);
170
+
171
+ // Persist completed status + _completionNotified via file lock
172
+ mutateJsonFileLocked(planPath, (data) => {
173
+ data.status = PLAN_STATUS.COMPLETED;
174
+ data.completedAt = plan.completedAt;
175
+ data._completionNotified = true;
176
+ return data;
177
+ });
178
+ }
161
179
 
162
180
  // Resolve the primary project for writing new work items (PR, verify).
163
181
  // Multi-project plans (no plan.project) derive primary from the done items —
@@ -255,6 +273,14 @@ function checkPlanCompletion(meta, config) {
255
273
  }
256
274
 
257
275
  if (isReopenableVerify(existingVerify)) {
276
+ // Re-opening a terminal verify is a plan-MODIFICATION action, not part of
277
+ // steady-state completion. The dashboard clears `_completionNotified` when
278
+ // it re-opens a plan, so only re-open here on a fresh completion; otherwise
279
+ // an already-done verify would bounce on every periodic scan (W-mqk5ld3p).
280
+ if (alreadyNotified) {
281
+ log('info', `Plan ${planFile}: verify WI ${existingVerify.id} for ${projName} already ${existingVerify.status} and plan already notified — leaving as-is`);
282
+ continue;
283
+ }
258
284
  const verifyProject = existingVerify.project || projName;
259
285
  const vProject = shared.resolveProjectSource(verifyProject, projects, { allowCentral: false }).project || p;
260
286
  const vWiPath = shared.projectWorkItemsPath(vProject);
@@ -8,7 +8,7 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const shared = require('./shared');
10
10
  const queries = require('./queries');
11
- const { safeJson, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
11
+ const { safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
12
12
  const routing = require('./routing');
13
13
  const http = require('http');
14
14
  const { shouldRunNow } = require('./scheduler');
@@ -70,7 +70,7 @@ function deletePipeline(id) {
70
70
  // ── Run State ────────────────────────────────────────────────────────────────
71
71
 
72
72
  function getPipelineRuns() {
73
- return safeJson(PIPELINE_RUNS_PATH) || {};
73
+ return safeJsonObj(PIPELINE_RUNS_PATH);
74
74
  }
75
75
 
76
76
  function getActiveRun(pipelineId) {
@@ -335,7 +335,7 @@ function evaluateCondition(condition, ctx) {
335
335
  // True when all work items created by the pipeline are done (not failed)
336
336
  if (!run) return false;
337
337
  const wiPath = CENTRAL_WI_PATH;
338
- const workItems = safeJson(wiPath) || [];
338
+ const workItems = safeJsonArr(wiPath);
339
339
  const allProjectWi = shared.getProjects(config).reduce((acc, p) => {
340
340
  return acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []);
341
341
  }, []);
@@ -876,7 +876,7 @@ function isStageComplete(stage, stageState, run, config) {
876
876
  case STAGE_TYPE.TASK: {
877
877
  // Check root + all project work-items.json (WIs may be moved to project paths)
878
878
  const wiPath = CENTRAL_WI_PATH;
879
- const workItems = safeJson(wiPath) || [];
879
+ const workItems = safeJsonArr(wiPath);
880
880
  const allProjectWi = shared.getProjects(config).reduce((acc, p) => {
881
881
  return acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []);
882
882
  }, []);
@@ -900,7 +900,7 @@ function isStageComplete(stage, stageState, run, config) {
900
900
  case STAGE_TYPE.PLAN: {
901
901
  // Plan stage completion: PRD conversion done + all materialized work items done
902
902
  const wiPath = CENTRAL_WI_PATH;
903
- const workItems = safeJson(wiPath) || [];
903
+ const workItems = safeJsonArr(wiPath);
904
904
  const allProjectWi = shared.getProjects(config).reduce((acc, p) => {
905
905
  return acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []);
906
906
  }, []);
@@ -1064,7 +1064,7 @@ async function discoverPipelineWork(config) {
1064
1064
  let output = '';
1065
1065
  if (stage.type === STAGE_TYPE.TASK) {
1066
1066
  const wiPath = CENTRAL_WI_PATH;
1067
- const workItems = safeJson(wiPath) || [];
1067
+ const workItems = safeJsonArr(wiPath);
1068
1068
  const projWi = shared.getProjects(config).reduce((acc, p) => acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []), []);
1069
1069
  const allWi = [...workItems, ...projWi];
1070
1070
  output = (stageState.artifacts?.workItems || []).map(id => {
@@ -88,7 +88,7 @@ function _centralDispatchDefaultedToProject(d, removedProject, projects) {
88
88
  function _collectProjectlessCentralDispatchItemIds(removedProject, projects) {
89
89
  const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
90
90
  const ids = new Set();
91
- const state = shared.safeJson(dispatchPath) || {};
91
+ const state = shared.safeJsonObj(dispatchPath);
92
92
  for (const queue of ['pending', 'active']) {
93
93
  for (const d of Array.isArray(state?.[queue]) ? state[queue] : []) {
94
94
  if (_centralDispatchDefaultedToProject(d, removedProject, projects)) ids.add(d.meta.item.id);
package/engine/routing.js CHANGED
@@ -8,7 +8,7 @@ const path = require('path');
8
8
  const shared = require('./shared');
9
9
  const queries = require('./queries');
10
10
 
11
- const { safeJson, safeRead, log, ts, WORK_TYPE } = shared;
11
+ const { safeJson, safeJsonObj, safeRead, log, ts, WORK_TYPE } = shared;
12
12
  const { ENGINE_DIR, DISPATCH_PATH } = queries;
13
13
 
14
14
  const MINIONS_DIR = shared.MINIONS_DIR;
@@ -84,7 +84,7 @@ function getMonthlySpend(agentId) {
84
84
 
85
85
  function getAgentErrorRate(agentId) {
86
86
  const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
87
- const metrics = safeJson(metricsPath) || {};
87
+ const metrics = safeJsonObj(metricsPath);
88
88
  const m = metrics[agentId];
89
89
  if (!m) return 0;
90
90
  const total = m.tasksCompleted + m.tasksErrored;
@@ -93,7 +93,7 @@ function getAgentErrorRate(agentId) {
93
93
 
94
94
  function isAgentIdle(agentId) {
95
95
  // Dispatch queue is the single source of truth for agent availability
96
- const dispatch = safeJson(DISPATCH_PATH) || {};
96
+ const dispatch = safeJsonObj(DISPATCH_PATH);
97
97
  return !(dispatch.active || []).some(d => d.agent === agentId);
98
98
  }
99
99
 
package/engine/shared.js CHANGED
@@ -6553,6 +6553,32 @@ function findPrRecord(prs, prRef, project = null) {
6553
6553
  return numberMatches.length === 1 ? numberMatches[0] : null;
6554
6554
  }
6555
6555
 
6556
+ // Issue #289: single source of truth for "is this stored PR title a
6557
+ // placeholder/fallback that should be backfilled from the live platform
6558
+ // title on the next poll?". Recognizes:
6559
+ // - empty / missing titles
6560
+ // - the link-time "...(polling...)" placeholder
6561
+ // - serialized-JSON / agent-output leakage (contains {}"[] chars)
6562
+ // - bare hex/uuid-ish ids (>= 8 hex/dash chars)
6563
+ // - the fallback WRITER shapes "PR created by <agent>" (engine/lifecycle.js)
6564
+ // and bare "PR #<n>" (engine/ado.js central poller)
6565
+ // The WRITER (engine/lifecycle.js) and the DETECTORS (engine/github.js,
6566
+ // engine/ado.js) MUST agree — route every site through this helper so they
6567
+ // cannot drift apart again (the github poller previously never backfilled a
6568
+ // frozen "PR created by Ripley" title because its detector didn't know the shape).
6569
+ function isPlaceholderPrTitle(title) {
6570
+ const t = typeof title === 'string'
6571
+ ? title.trim()
6572
+ : (title == null ? '' : String(title).trim());
6573
+ if (!t) return true;
6574
+ if (t.includes('polling...')) return true;
6575
+ if (/[{}"\[\]]/.test(t)) return true;
6576
+ if (/^[0-9a-f-]{8,}$/i.test(t)) return true;
6577
+ if (/^PR created by /i.test(t)) return true;
6578
+ if (/^PR #\d+$/i.test(t)) return true;
6579
+ return false;
6580
+ }
6581
+
6556
6582
  function snapshotPrRecord(pr) {
6557
6583
  if (pr === undefined) return undefined;
6558
6584
  return JSON.parse(JSON.stringify(pr));
@@ -7197,6 +7223,23 @@ function killImmediate(proc) {
7197
7223
  }
7198
7224
  }
7199
7225
 
7226
+ // Decide whether a Windows `tasklist /FI "PID eq <pid>" /NH` output proves the
7227
+ // pid is alive. A bare `out.includes(String(pid))` can read a DEAD pid as alive
7228
+ // on a digit-substring collision — the pid appears inside another column (a
7229
+ // larger PID, a memory-KB figure, a session id). Require a word-boundary match
7230
+ // (`\b<pid>\b`) and, when an `imageName` is supplied, that the expected process
7231
+ // image is present too. Mirrors the hardened check in engine/restart-health.js.
7232
+ // Pure (no shell-out) so callers can pass captured output and unit tests can
7233
+ // feed crafted samples.
7234
+ function tasklistOutputShowsPid(out, pid, { imageName } = {}) {
7235
+ if (!out) return false;
7236
+ const n = Number(pid);
7237
+ if (!Number.isInteger(n) || n <= 0) return false;
7238
+ if (!new RegExp(`\\b${n}\\b`).test(out)) return false;
7239
+ if (imageName && !out.toLowerCase().includes(String(imageName).toLowerCase())) return false;
7240
+ return true;
7241
+ }
7242
+
7200
7243
  // W-mq0e2dae000a003d — cross-platform CPU-seconds sampler used by the
7201
7244
  // spawn-phase watchdog to decide whether a process is genuinely wedged
7202
7245
  // vs busy. Returns the cumulative user+system CPU time in seconds, or
@@ -8409,6 +8452,32 @@ function hasWorktreeOwnerMarker(worktreePath) {
8409
8452
  }
8410
8453
  }
8411
8454
 
8455
+ // True when a `git status --porcelain` line refers ONLY to the engine's own
8456
+ // worktree-ownership marker (.minions-worktree) at the worktree root (#284).
8457
+ // The marker is gitignored in the repo's current tree, but a reused worktree
8458
+ // that checks out a branch whose tree predates that .gitignore line (or a
8459
+ // foreign repo) surfaces the marker as an untracked `?? .minions-worktree`
8460
+ // entry. Counting that engine-stamped artifact as "dirty" would fail (and
8461
+ // quarantine) an otherwise-safe-to-reuse worktree. Callers filter the marker
8462
+ // out of the dirty-file list before deciding a worktree is dirty.
8463
+ function isWorktreeOwnerMarkerStatusLine(porcelainLine) {
8464
+ if (!porcelainLine) return false;
8465
+ const line = String(porcelainLine).trim();
8466
+ if (!line) return false;
8467
+ // Porcelain v1: "XY <path>" — for an untracked file XY is "??". Strip the
8468
+ // status code (1-2 chars from the porcelain status alphabet) and the
8469
+ // following whitespace; fall back to the raw line if it doesn't match.
8470
+ const m = line.match(/^[ MTADRCU?!]{1,2}\s+(.*)$/);
8471
+ let p = m ? m[1] : line;
8472
+ // git quotes paths containing special chars; the marker name never needs it
8473
+ // but strip defensively. Then normalize separators and a leading "./".
8474
+ if (p.length >= 2 && p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1);
8475
+ p = p.replace(/\\/g, '/').replace(/^\.\//, '');
8476
+ // The marker is only ever stamped at the worktree root — a same-named file
8477
+ // nested in a subdir is NOT the ownership marker and stays "dirty".
8478
+ return p === WORKTREE_OWNER_MARKER;
8479
+ }
8480
+
8412
8481
  function slugify(text, maxLen = 50) {
8413
8482
  return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, maxLen);
8414
8483
  }
@@ -8791,6 +8860,7 @@ module.exports = {
8791
8860
  isAdoPrScopeCompatible,
8792
8861
  getCanonicalPrId,
8793
8862
  findPrRecord,
8863
+ isPlaceholderPrTitle,
8794
8864
  snapshotPrRecord,
8795
8865
  applyPrFieldDelta,
8796
8866
  normalizePrRecord,
@@ -8860,6 +8930,7 @@ module.exports = {
8860
8930
  sleepMs,
8861
8931
  killGracefully,
8862
8932
  killImmediate,
8933
+ tasklistOutputShowsPid,
8863
8934
  getProcessCpuSeconds,
8864
8935
  killByPidImmediate,
8865
8936
  killByPidsImmediate,
@@ -8875,6 +8946,7 @@ module.exports = {
8875
8946
  WORKTREE_OWNER_MARKER,
8876
8947
  writeWorktreeOwnerMarker,
8877
8948
  hasWorktreeOwnerMarker,
8949
+ isWorktreeOwnerMarkerStatusLine,
8878
8950
  _normalizeWorktreePath, // exported for testing
8879
8951
  _writeWorktreeSkipLiveInboxNote, // exported for testing
8880
8952
  _retryFsOp, // exported for testing (W-mq5o6bvy000x7191)
@@ -103,7 +103,14 @@ function isPidAlive(pid) {
103
103
  const out = execSync(`tasklist /FI "PID eq ${pid}" /NH`, {
104
104
  encoding: 'utf8', timeout: 3000, windowsHide: true,
105
105
  });
106
- return out.includes(String(pid));
106
+ // Word-boundary + image-name match (mirrors engine/restart-health.js) so a
107
+ // digit-substring collision in another column can't read a dead pid as
108
+ // alive. Fall back to an inline regex if shared.js isn't loadable.
109
+ const shared = _sharedOrNull();
110
+ if (shared && typeof shared.tasklistOutputShowsPid === 'function') {
111
+ return shared.tasklistOutputShowsPid(out, pid, { imageName: 'node' });
112
+ }
113
+ return new RegExp(`\\b${pid}\\b`).test(out) && out.toLowerCase().includes('node');
107
114
  }
108
115
  process.kill(pid, 0);
109
116
  return true;
@@ -180,12 +187,30 @@ function _tokenizeCmdline(cmdline) {
180
187
  // byte-identical `path.join(MINIONS_DIR, '<script>.js')`, so exact script-token
181
188
  // match catches all of them while a foreign process that only *names* the path
182
189
  // is left alone (fail-safe: never kill on a loose match).
190
+ //
191
+ // ENGINE subcommand gate: for an `engine.js` target we additionally require the
192
+ // token AFTER the script to be `start`, so a short-lived `node engine.js stop`
193
+ // one-shot (spawned by `minions restart`/`down`/`update` and the `stop` verb
194
+ // during the restart window) is NEVER reaped. The long-lived daemon is ALWAYS
195
+ // spawned with `'start'` (supervisor/dashboard spawnEngine + bin/minions.js), so
196
+ // the requirement loses zero real orphans. The `dashboard.js` match stays BARE
197
+ // (no subcommand) — only the engine candidate gets the `start` requirement.
183
198
  function _cmdRunsScript(cmdline, target) {
184
199
  const toks = _tokenizeCmdline(cmdline);
200
+ const isEngineTarget = target === 'engine.js' || target.endsWith('/engine.js');
185
201
  // toks[0] is the node executable; the script is the first later .js token.
186
202
  for (let i = 1; i < toks.length; i++) {
187
203
  const norm = _normPath(toks[i]);
188
- if (norm.endsWith('.js')) return norm === target;
204
+ if (norm.endsWith('.js')) {
205
+ if (norm !== target) return false;
206
+ if (isEngineTarget) {
207
+ // Only the daemon (`engine.js start`) is reapable; one-shots like
208
+ // `engine.js stop` are left alone.
209
+ const next = toks[i + 1] ? _normPath(toks[i + 1]) : '';
210
+ return next === 'start';
211
+ }
212
+ return true;
213
+ }
189
214
  }
190
215
  return false;
191
216
  }
package/engine.js CHANGED
@@ -1454,7 +1454,13 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
1454
1454
  return result;
1455
1455
  }
1456
1456
  if (statusOut) {
1457
- result.dirtyFiles = statusOut.split('\n').map(l => l.trim()).filter(Boolean);
1457
+ result.dirtyFiles = statusOut.split('\n').map(l => l.trim()).filter(Boolean)
1458
+ // #284: the engine's own .minions-worktree ownership marker can surface
1459
+ // as an untracked entry when the checked-out tree predates the .gitignore
1460
+ // line that excludes it (or it's a foreign repo). An untracked marker
1461
+ // alone is a CLEAN tree for reuse — never let the engine fail/quarantine
1462
+ // a work item over an artifact it stamped itself.
1463
+ .filter(l => !shared.isWorktreeOwnerMarkerStatusLine(l));
1458
1464
  }
1459
1465
  const filesystemDirty = result.dirtyFiles.length > 0;
1460
1466
 
@@ -1597,9 +1603,15 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
1597
1603
  try {
1598
1604
  const r2 = await execAsync(_statusPorcelainCmd(), { ...gitOpts, cwd: worktreePath, timeout: statusTimeoutMs });
1599
1605
  const after = (r2 || '').toString().trim();
1600
- if (after) {
1606
+ // #284: ignore the ownership marker here too — `git clean -fd` removes it,
1607
+ // but the engine may re-stamp it, and it must never count as residual dirt.
1608
+ const afterFiles = after
1609
+ ? after.split('\n').map(l => l.trim()).filter(Boolean)
1610
+ .filter(l => !shared.isWorktreeOwnerMarkerStatusLine(l))
1611
+ : [];
1612
+ if (afterFiles.length) {
1601
1613
  result.reason = 'dirty-after-clean';
1602
- result.dirtyFiles = after.split('\n').map(l => l.trim()).filter(Boolean);
1614
+ result.dirtyFiles = afterFiles;
1603
1615
  return result;
1604
1616
  }
1605
1617
  } catch (e) {
@@ -2831,6 +2843,18 @@ async function spawnAgent(dispatchItem, config) {
2831
2843
  const failureClassName = isQuarantineEnvBlocked
2832
2844
  ? 'WORKTREE_QUARANTINE_ENV_BLOCKED'
2833
2845
  : (isDivergent ? 'WORKTREE_DIVERGENT' : 'WORKTREE_DIRTY');
2846
+ // #284: distinguish the ORIGINAL worktree-preflight failure from a
2847
+ // failure on the automatic fresh-worktree retry. The quarantine
2848
+ // auto-recovery loop (discoverFromWorkItems) flips a failed
2849
+ // WORKTREE_DIRTY/DIVERGENT item back to pending and stamps
2850
+ // _quarantineRecoveryCount on the WI; if this dispatch (a re-dispatch
2851
+ // of that item) hits the preflight gate AGAIN, the fresh worktree did
2852
+ // not resolve the dirt — surface that in the completion report so a
2853
+ // human/the engine can tell the two apart.
2854
+ const priorQuarantineRetry = Number(meta?.item?._quarantineRecoveryCount) || 0;
2855
+ const retryLabel = priorQuarantineRetry > 0
2856
+ ? ` [retry-in-fresh-worktree #${priorQuarantineRetry} also failed]`
2857
+ : ' [original worktree-preflight issue]';
2834
2858
  const reasonMsg = cleanResult.quarantined
2835
2859
  ? `${failureClassName}: reused worktree at ${worktreePath} was dirty/divergent (${cleanResult.reason}; ${cleanResult.ahead || 0} ahead, ${cleanResult.behind || 0} behind, ${cleanResult.dirtyFiles?.length || 0} dirty file(s)${previewFiles ? ': ' + previewFiles : ''}) — quarantined to ${cleanResult.quarantinedPath}. Next dispatch will start fresh.`
2836
2860
  : `${failureClassName}: reused worktree at ${worktreePath} is dirty/divergent (${cleanResult.reason}; ${cleanResult.ahead || 0} ahead, ${cleanResult.behind || 0} behind, ${cleanResult.dirtyFiles?.length || 0} file(s)${previewFiles ? ': ' + previewFiles : ''}). Quarantine ${cleanResult.quarantineError ? 'errored: ' + cleanResult.quarantineError : (cleanResult.quarantineSkipped ? 'was skipped — another live dispatch claims the worktree (see notes/inbox/ engine-worktree-skip-live note).' : 'was not attempted (' + cleanResult.reason + ').')}`;
@@ -2840,7 +2864,7 @@ async function spawnAgent(dispatchItem, config) {
2840
2864
  id,
2841
2865
  DISPATCH_RESULT.ERROR,
2842
2866
  reasonMsg.slice(0, 500),
2843
- `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.` : (cleanResult.quarantineSkipped ? ' Quarantine was skipped because another live dispatch claims this worktree path; this dispatch will not auto-retry until the live claimant clears.' : '')}${isQuarantineEnvBlocked ? ' Environmental quarantine failure (Windows EBUSY); WI auto-recovery loop will re-queue without bumping per-agent retry counter.' : ''}`,
2867
+ `Engine preflight refused to dispatch into a dirty/divergent reused worktree (#2996).${retryLabel} Reason: ${cleanResult.reason}.${cleanResult.quarantined ? ` Worktree quarantined to ${cleanResult.quarantinedPath}; backup ref ${cleanResult.backupRef || '(skipped)'}. See notes/inbox/ for recovery instructions.` : (cleanResult.quarantineSkipped ? ' Quarantine was skipped because another live dispatch claims this worktree path; this dispatch will not auto-retry until the live claimant clears.' : '')}${isQuarantineEnvBlocked ? ' Environmental quarantine failure (Windows EBUSY); WI auto-recovery loop will re-queue without bumping per-agent retry counter.' : ''}`,
2844
2868
  { agentRetryable: isStatusProbeFailed && cleanResult.quarantined, failureClass: failureClassValue },
2845
2869
  );
2846
2870
  cleanupTempAgent(agentId);
@@ -3347,8 +3371,12 @@ async function spawnAgent(dispatchItem, config) {
3347
3371
  try {
3348
3372
  const dirtyResult = await execAsync(_statusPorcelainCmd(), { ..._gitOpts, cwd: worktreePath, timeout: 10000 });
3349
3373
  const dirtyOutput = (dirtyResult.stdout || '').trim();
3350
- if (dirtyOutput) {
3351
- const dirtyFiles = dirtyOutput.split('\n').map(l => l.trim()).filter(Boolean);
3374
+ const dirtyFiles = dirtyOutput
3375
+ ? dirtyOutput.split('\n').map(l => l.trim()).filter(Boolean)
3376
+ // #284: don't surface the engine's own ownership marker as "prior work".
3377
+ .filter(l => !shared.isWorktreeOwnerMarkerStatusLine(l))
3378
+ : [];
3379
+ if (dirtyFiles.length) {
3352
3380
  const dirtySection = [
3353
3381
  '\n## Uncommitted Work in Worktree\n',
3354
3382
  'The worktree has uncommitted changes from a previous agent run. Review these files and continue from where the previous agent left off.\n',
@@ -8704,8 +8732,16 @@ async function discoverWork(config) {
8704
8732
  // readdir and read (e.g. concurrent archive), do not resurrect it
8705
8733
  // from a stale .backup sidecar (W-mouptdh1000h9f39).
8706
8734
  const plan = safeJsonNoRestore(path.join(prdDir, f));
8707
- if (!plan?.missing_features || plan.status === PLAN_STATUS.COMPLETED) {
8708
- if (plan?.status === PLAN_STATUS.COMPLETED) completedPlanCache.add(f);
8735
+ if (!plan?.missing_features) continue;
8736
+ // A completed PRD may still be missing its aggregate verify WI — e.g. it
8737
+ // landed on disk pre-completed via the plan-to-prd / pipeline path, so no
8738
+ // agent-completion event ever drove checkPlanCompletion (W-mqk5ld3p). The
8739
+ // function is idempotent (re-checks for an existing verify WI under lock),
8740
+ // so run it for completed plans too and cache only once it reports nothing
8741
+ // left to do (truthy return).
8742
+ if (plan.status === PLAN_STATUS.COMPLETED) {
8743
+ const done = lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
8744
+ if (done) completedPlanCache.add(f);
8709
8745
  continue;
8710
8746
  }
8711
8747
  if (plan.status !== PLAN_STATUS.APPROVED && plan.status !== PLAN_STATUS.ACTIVE) continue;
@@ -9258,11 +9294,13 @@ async function tickInner() {
9258
9294
  continue;
9259
9295
  }
9260
9296
  const plan = safeJson(path.join(PRD_DIR, file));
9261
- if (plan && plan.missing_features && plan.status !== PLAN_STATUS.COMPLETED) {
9297
+ if (plan && plan.missing_features) {
9298
+ // Run for completed PRDs too — a pre-completed plan (flag already set,
9299
+ // items pre-marked done) still needs its aggregate verify gate created.
9300
+ // checkPlanCompletion is idempotent; cache only once it reports nothing
9301
+ // left to do (truthy return) (W-mqk5ld3p).
9262
9302
  const completed = checkPlanCompletion({ item: { sourcePlan: file } }, config);
9263
9303
  if (completed) completedPlanCache.add(file);
9264
- } else if (plan?.status === PLAN_STATUS.COMPLETED) {
9265
- completedPlanCache.add(file);
9266
9304
  }
9267
9305
  }
9268
9306
  } catch (err) { log('warn', `Plan completion check error: ${err?.message || err}`); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2227",
3
+ "version": "0.1.2229",
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"
@@ -153,7 +153,11 @@ Copyable worked example:
153
153
 
154
154
  Entries you report are **cross-checked** against what the engine propagated into your worktree and annotated `grounded: true | false` — never dropped — so a `grounded: false` entry surfaces a discrepancy for a human rather than being hidden. Full contract: `docs/harness-transparency.md`.
155
155
 
156
- When you author a **PR review / fix summary comment** (GitHub `minions pr comment` / `gh pr comment`, or Azure DevOps `az repos pr comment`), fold the same harness footprint into the body as a collapsible section so reviewers see which skills / MCPs / docs informed the change. Render it **identically on both hosts** — append this exact `<details>` block at the end of your comment body (omit it entirely when you used no affordances):
156
+ When you author a **PR review / fix summary comment**, the same harness footprint belongs in the comment body as a collapsible "Harnesses used" section so reviewers see which skills / MCPs / docs informed the change.
157
+
158
+ **Preferred — `minions pr comment` (GitHub and Azure DevOps `--host ado`): do NOT hand-render the section.** Write your `harnessUsed` record — the same `{ skills, mcpServers, commands, docs }` shape as the completion field above — to a JSON file and pass `--harness-file <path>` (or inline via `--harness-json <json>`). The shared builder (`engine/comment-format.js#buildHarnessUsedSection`, consumed by both `engine/gh-comment.js` and `engine/ado-comment.js`) folds the byte-identical `<details>` section into the body for you, so agent-authored and engine-authored comments match. Each entry is cross-checked against the harness manifest the engine propagated into your worktree and annotated `grounded: true | false` (never dropped); a `grounded: false`, ⚠️-marked bullet means **the engine couldn't confirm that affordance against the propagated manifest — not that you did anything wrong**.
159
+
160
+ **Raw fallbacks only — `gh pr comment` / `az repos pr comment` / ADO REST:** these bypass the CLI chokepoint, so you MUST hand-render the section yourself. Render it **identically on both hosts** — append this exact `<details>` block at the end of your comment body (omit it entirely when you used no affordances):
157
161
 
158
162
  ```markdown
159
163
  <details>
@@ -167,7 +171,7 @@ When you author a **PR review / fix summary comment** (GitHub `minions pr commen
167
171
  </details>
168
172
  ```
169
173
 
170
- One bullet per affordance, in the order skills → MCP servers → commands → docs; `N` is the bullet count. This is the human-readable mirror of your `harnessUsed` completion field keep the two consistent. The engine renders the byte-identical section via `engine/comment-format.js#buildHarnessUsedSection` when it composes a comment, so agent-authored and engine-authored sections match.
174
+ One bullet per affordance, in the order skills → MCP servers → commands → docs; `N` is the bullet count. Whether the CLI folds it in or you hand-render it on a raw fallback, the section is the human-readable mirror of your `harnessUsed` completion field keep the two consistent.
171
175
 
172
176
  ## Minions API access
173
177