@yemi33/minions 0.1.2293 → 0.1.2295

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.
@@ -289,32 +289,6 @@ async function openSettings() {
289
289
  '⚠ Live mode: dispatches run directly in this repo\'s checkout. Only one mutating dispatch runs at a time. Dirty working trees block dispatch — commit or stash before running.' +
290
290
  '</div>' +
291
291
  '</div>';
292
- // M006 — liveValidation section (per-project hybrid mode config).
293
- // Controls: type (work item type that stays serialized in live checkout)
294
- // and autoDispatch (auto-create validation WI after coding WI completes).
295
- // Section is rendered with reduced opacity + pointer-events:none when
296
- // checkoutMode is not 'live' — it only applies in hybrid mode.
297
- var lvType = (p.liveValidation && p.liveValidation.type) ? p.liveValidation.type : '';
298
- var lvAutoDispatch = !!(p.liveValidation && p.liveValidation.autoDispatch);
299
- var lvDisabled = (currentWtMode !== 'live');
300
- var lvSectionStyle = lvDisabled
301
- ? 'opacity:0.45;pointer-events:none;margin-bottom:6px'
302
- : 'margin-bottom:6px';
303
- var liveValidationBlock =
304
- '<div data-live-validation-section="' + escHtml(p.name) + '" data-search="live validation deferred build test auto dispatch worktree" style="' + lvSectionStyle + '">' +
305
- '<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">Live validation (deferred build/test)' +
306
- (lvDisabled ? ' <span style="font-size:var(--text-xs);opacity:0.7">(requires Live checkout)</span>' : '') +
307
- '</label>' +
308
- '<div style="font-size:var(--text-xs);color:var(--muted);margin-bottom:4px;line-height:1.4">' +
309
- 'For checkoutMode: live projects — coding agents run in worktrees; a separate dispatch validates in live checkout.' +
310
- '</div>' +
311
- '<input id="set-liveValidationType-' + escHtml(p.name) + '" value="' + escHtml(lvType) + '" placeholder="e.g. build-and-test" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md);margin-bottom:4px">' +
312
- '<div style="font-size:var(--text-xs);color:var(--muted);margin-bottom:4px">Validation work item type (e.g. <code>build-and-test</code>, <code>test</code>, <code>verify</code>). Leave blank to disable.</div>' +
313
- '<div style="display:flex;align-items:center;gap:8px;padding:2px 0">' +
314
- '<input type="checkbox" id="set-liveValidationAutoDispatch-' + escHtml(p.name) + '"' + (lvAutoDispatch ? ' checked' : '') + ' style="accent-color:var(--blue);width:16px;height:16px;cursor:pointer">' +
315
- '<label for="set-liveValidationAutoDispatch-' + escHtml(p.name) + '" style="font-size:var(--text-md);color:var(--text);cursor:pointer">Auto-dispatch validation WI after coding WI completes</label>' +
316
- '</div>' +
317
- '</div>';
318
292
  return '<div data-settings-project="' + escHtml(p.name) + '" data-search="project ' + escHtml(p.name.toLowerCase()) + '" style="border:1px solid var(--border);border-radius:6px;padding:10px 12px;margin-bottom:12px">' +
319
293
  '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">' +
320
294
  '<div style="font-size:var(--text-md);font-weight:600">' + escHtml(p.name) + '</div>' +
@@ -323,7 +297,6 @@ async function openSettings() {
323
297
  pathRow +
324
298
  branchGrid +
325
299
  worktreeModeBlock +
326
- liveValidationBlock +
327
300
  driftNote +
328
301
  '<div style="display:flex;flex-direction:column;gap:6px;margin-top:8px">' +
329
302
  settingsToggle('Discover from PRs', 'set-ws-prs-' + p.name, p.workSources.pullRequests.enabled, 'Discovery gate: scan repo for open PRs and surface them as review tasks. Independent of ADO/GitHub polling — does not affect already-tracked PRs.') +
@@ -729,14 +702,6 @@ async function openSettings() {
729
702
  const chip = document.querySelector('[data-checkout-mode-chip="' + (window.CSS && CSS.escape ? CSS.escape(projName) : projName) + '"]');
730
703
  if (!chip) return;
731
704
  chip.style.display = (sel.value === 'live') ? '' : 'none';
732
- // M006 — also toggle the liveValidation section opacity/pointer-events
733
- // reactively: disabled when checkoutMode is not 'live'.
734
- const lvSection = document.querySelector('[data-live-validation-section="' + (window.CSS && CSS.escape ? CSS.escape(projName) : projName) + '"]');
735
- if (lvSection) {
736
- const isLive = (sel.value === 'live');
737
- lvSection.style.opacity = isLive ? '' : '0.45';
738
- lvSection.style.pointerEvents = isLive ? '' : 'none';
739
- }
740
705
  });
741
706
  });
742
707
  }
@@ -1181,20 +1146,10 @@ async function saveSettings() {
1181
1146
  // values.
1182
1147
  const wtModeInput = document.getElementById('set-checkoutMode-' + p.name);
1183
1148
  const wtModeValue = (wtModeInput && wtModeInput.value === 'live') ? 'live' : 'worktree';
1184
- // M006 — liveValidation: read type text input and autoDispatch checkbox.
1185
- // Empty type → send null so the server clears the field.
1186
- const lvTypeInput = document.getElementById('set-liveValidationType-' + p.name);
1187
- const lvAutoDispatchInput = document.getElementById('set-liveValidationAutoDispatch-' + p.name);
1188
- const lvTypeValue = lvTypeInput ? lvTypeInput.value.trim() : '';
1189
- const lvAutoDispatchValue = lvAutoDispatchInput ? !!lvAutoDispatchInput.checked : false;
1190
- const liveValidationValue = lvTypeValue
1191
- ? { type: lvTypeValue, autoDispatch: lvAutoDispatchValue }
1192
- : null;
1193
1149
  return {
1194
1150
  name: p.name,
1195
1151
  mainBranch: mainBranchValue || null,
1196
1152
  checkoutMode: wtModeValue,
1197
- liveValidation: liveValidationValue,
1198
1153
  workSources: {
1199
1154
  pullRequests: { enabled: document.getElementById('set-ws-prs-' + p.name)?.checked ?? true },
1200
1155
  workItems: { enabled: document.getElementById('set-ws-wi-' + p.name)?.checked ?? true }
package/dashboard.js CHANGED
@@ -430,21 +430,6 @@ function mergeSettingsConfigUpdate(current, candidate, body, patch = {}) {
430
430
  // `worktreeMode`. Drop any stale legacy key on every settings save so a
431
431
  // migrated project never carries both fields.
432
432
  delete currentProject.worktreeMode;
433
- // M006 — mirror liveValidation: check both candidateProject (modified
434
- // in-memory config) and the original body update. When handleSettingsUpdate
435
- // deletes the field (null or empty type sent), candidateProject won't have
436
- // it but the body update will — mirror the deletion to disk.
437
- if (Object.prototype.hasOwnProperty.call(candidateProject, 'liveValidation')) {
438
- if (candidateProject.liveValidation && typeof candidateProject.liveValidation === 'object' && candidateProject.liveValidation.type) {
439
- currentProject.liveValidation = candidateProject.liveValidation;
440
- } else {
441
- delete currentProject.liveValidation;
442
- }
443
- } else if (Object.prototype.hasOwnProperty.call(update, 'liveValidation')) {
444
- // Body sent liveValidation key but handleSettingsUpdate deleted it from
445
- // the in-memory config (null / empty type) — propagate to disk.
446
- delete currentProject.liveValidation;
447
- }
448
433
  }
449
434
  }
450
435
  shared.pruneDefaultClaudeConfig(current);
@@ -10921,11 +10906,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10921
10906
  // the per-project dropdown. resolveCheckoutMode honors the legacy
10922
10907
  // worktreeMode field; 'worktree' (default) or 'live'.
10923
10908
  checkoutMode: shared.resolveCheckoutMode(p),
10924
- // M006 — surface liveValidation so the Settings UI can pre-fill
10925
- // the type input and autoDispatch toggle. Null when not configured.
10926
- liveValidation: (p.liveValidation && typeof p.liveValidation === 'object' && p.liveValidation.type)
10927
- ? { type: p.liveValidation.type, autoDispatch: !!p.liveValidation.autoDispatch }
10928
- : null,
10929
10909
  workSources: {
10930
10910
  pullRequests: { enabled: p.workSources?.pullRequests?.enabled !== false, cooldownMinutes: p.workSources?.pullRequests?.cooldownMinutes ?? 30 },
10931
10911
  workItems: { enabled: p.workSources?.workItems?.enabled !== false, cooldownMinutes: p.workSources?.workItems?.cooldownMinutes ?? 0 }
@@ -11350,19 +11330,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11350
11330
  // Drop the legacy field so a migrated project never carries both.
11351
11331
  delete proj.worktreeMode;
11352
11332
  }
11353
- // M006 — per-project liveValidation: { type, autoDispatch }.
11354
- // Only meaningful when checkoutMode is 'live'. Null / missing type
11355
- // clears the field; explicit object with a non-empty type persists it.
11356
- if (Object.prototype.hasOwnProperty.call(update, 'liveValidation')) {
11357
- const lv = update.liveValidation;
11358
- if (!lv || typeof lv !== 'object' || !lv.type || typeof lv.type !== 'string' || !lv.type.trim()) {
11359
- delete proj.liveValidation;
11360
- } else {
11361
- proj.liveValidation = { type: lv.type.trim(), autoDispatch: !!lv.autoDispatch };
11362
- }
11363
- }
11364
11333
  }
11365
11334
  }
11335
+
11336
+ shared.pruneDefaultClaudeConfig(config);
11366
11337
  mutateDashboardConfig(current => mergeSettingsConfigUpdate(current, config, body, _configPatch));
11367
11338
  // Refresh in-memory CONFIG so subsequent reads see the update
11368
11339
  reloadConfig();
@@ -13427,7 +13398,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13427
13398
  // /api/prd/regenerate removed — use /api/plans/approve which does diff-aware update
13428
13399
 
13429
13400
  // Agents
13430
- { method: 'POST', path: '/api/pull-requests/link', desc: 'Manually link an external PR for tracking', params: 'url, title?, project?, contextOnly?, context?, workItemId?', handler: async (req, res) => {
13401
+ { method: 'POST', path: '/api/pull-requests/link', desc: 'Manually link an external PR for tracking', params: 'url, title?, project?, contextOnly?, autoObserve? (deprecated alias for !contextOnly), context?, workItemId?', handler: async (req, res) => {
13431
13402
  const body = await readBody(req);
13432
13403
  const { url } = body;
13433
13404
  if (!url) return jsonReply(res, 400, { error: 'url required' });
@@ -52,6 +52,7 @@
52
52
  "LEGACY_NEEDS_REVIEW_STATUS"
53
53
  ],
54
54
  "reason": "Read-side tolerance: cleanup sweep auto-migrates four obsolete work-item / PRD status strings ('in-pr', 'implemented', 'complete', 'needs-human-review') to the canonical 'done' / 'failed' values. The aliases are no longer written anywhere in the engine; the constants exist only to repair stale on-disk values from old engine versions.",
55
+ "status": "active",
55
56
  "targetRemovalDate": null,
56
57
  "notes": "Keep indefinitely until telemetry / a sweep log shows zero migrations performed for 30 consecutive days across all known projects (work-items.json + prd/*.json). At that point the constants and both _migrateLegacyItem branches in engine/cleanup.js (definitions at :1165-1166; usage at :1168-1183 for work items and :1269-1272 for PRD missing_features) can be deleted. Total cost on disk today: 4 strings."
57
58
  },
@@ -76,6 +77,7 @@
76
77
  }
77
78
  ],
78
79
  "removalGate": "Telemetry: the `deprecated-config-claude` warning emitted at engine/shared.js:2492-2495 must report zero hits across all known engines for >=30 consecutive days, AND a sweep of every persisted config.json must show no `config.claude.binary` value that diverges from DEFAULT_CLAUDE.binary. Only then is the override branch in resolveBinary() (engine/runtimes/claude.js:82-86) removable, along with the `_deprecatedConfigClaudeFields` membership for `binary` and the warning emitter at engine/shared.js:2482-2496.",
80
+ "status": "active",
79
81
  "targetRemovalDate": null,
80
82
  "notes": "Do NOT set targetRemovalDate — removal must be signal-gated, not calendar-gated. This entry is paired with `prune-default-claude-config`: the prune strips DEFAULT-matching values but intentionally preserves user overrides, which is precisely why the override branch in claude.js stays reachable. Removing the override before the prune entry's gate clears would silently break installs that still rely on a custom binary path."
81
83
  },
@@ -120,6 +122,7 @@
120
122
  }
121
123
  ],
122
124
  "removalGate": "Telemetry: the once-per-boot deprecation log line emitted by applyLegacyCcModelMigration (via the injected logger at engine/shared.js:2407) must show zero promotion events across all known engines for >=30 consecutive days, AND a sweep of every persisted config.json must confirm no `engine.ccModel` field remains. Once both conditions hold, removal deletes the function + _resetLegacyCcModelMigrationFlag export at engine/shared.js:4977, the boot call at engine/cli.js:477, the CLAUDE.md:316 paragraph and docs/slim-ux/concepts.md:671 reference, and the tests at runtime-fleet-helpers.test.js:209-254 + :500-505 + unit.test.js:19801.",
125
+ "status": "active",
123
126
  "targetRemovalDate": null,
124
127
  "notes": "Do NOT set targetRemovalDate — gating is signal-based. The function is silent on no-op (returns false without logging), so the meaningful telemetry signal is the absence of the promotion log line over the sweep window, NOT the absence of function invocations (cli.js calls it every boot regardless)."
125
128
  },
@@ -162,6 +165,7 @@
162
165
  }
163
166
  ],
164
167
  "removalGate": "All direct-readers of the mirror JSON files must be confirmed routed through their respective SQL store's read helper. Specifically: (a) grep the codebase for `safeJson`, `safeJsonArr`, `safeJsonObj`, `readFileSync(...work-items.json|pull-requests.json|metrics.json|watches.json|schedule-runs.json|pipeline-runs.json|managed-processes.json|worktree-pool.json|log.json|dispatch.json...)` and confirm every hit is either (i) a test fixture that can move to the SQL helper, or (ii) intentionally documented as bypassing SQL. (b) Run the full test suite with each store's _mirrorJsonFromSql temporarily neutered (returning early before safeWrite) and confirm 0 failures — that proves no production code path depends on the mirror. Once both conditions hold, removal deletes each store's _mirrorJsonFromSql call site in shared.js (mutateWorkItems/mutatePullRequests/etc.), the corresponding _readJsonArrayFallback paths, and the JSON file gitignore entries. CLAUDE.md update can ship independently as soon as someone has bandwidth.",
168
+ "status": "active",
165
169
  "targetRemovalDate": null,
166
170
  "notes": "Do NOT set targetRemovalDate — gating is signal-based, not calendar-based. The mirror writes are cheap (a few KB per write, sub-ms) so there is no production cost to keeping them indefinitely; the only reason to remove them is to simplify the codebase and lock in SQL-as-the-single-source-of-truth. Order matters: when retiring a specific store's mirror, retire the corresponding CLAUDE.md mention in the same PR so the docs never claim SQL-only while a mirror still writes."
167
171
  },
@@ -216,6 +220,7 @@
216
220
  }
217
221
  ],
218
222
  "removalGate": "Telemetry: pruneDefaultClaudeConfig must return false (no mutation) for every call across all known engines for >=30 consecutive days (add an `_engine.pruneDefaultClaudeConfigStrips` counter if needed to observe this), AND the parent `config-claude-binary-override` entry must have already cleared its own gate. The dependency is strict: removing the prune while users still rely on the override branch would surface the `deprecated-config-claude` warning on every stale generated default. Once both conditions hold, removal is the function definition (engine/shared.js:3126), the export at :5673, all 5 call sites (dashboard.js:202, :9116, :9331, :9450; minions.js:385), and the tests at unit.test.js:2260-2303 + runtime-fleet-helpers.test.js:546.",
223
+ "status": "active",
219
224
  "targetRemovalDate": null,
220
225
  "notes": "Do NOT set targetRemovalDate — gating is signal-based AND ordered. This entry MUST NOT be removed before `config-claude-binary-override` clears its gate, otherwise installs with stale defaults will flood the deprecation channel until their next config save. The 5 call sites form a complete coverage net: load (dashboard.js:202 + minions.js:385) + save (dashboard.js:9116/9331/9450), so any code path that touches config.json runs the sanitizer."
221
226
  },
@@ -281,6 +286,7 @@
281
286
  }
282
287
  ],
283
288
  "deprecated": "2026-06-08",
289
+ "status": "active",
284
290
  "targetRemovalDate": null,
285
291
  "notes": "targetRemovalDate intentionally null — unlike the record-field aliases (`_contextOnly`, `_autoObserve`, `_manual`) which carry a 7-day clock, the `observe` body param is documented as a longer-lived back-compat alias. Set targetRemovalDate to a concrete future date once the dashboard UI + any client scripts are confirmed to POST `contextOnly` exclusively. Removal scope when the date is set: drop the `body.observe` fallback in dashboard.js, drop `observe` from the route registry params, and update any client still POSTing `observe`."
286
292
  },
@@ -173,60 +173,6 @@ git for-each-ref --format '%(refname:short) %(upstream:track)' refs/heads \
173
173
 
174
174
  The engine has no opinion about local branches; this hygiene is the operator's responsibility in live mode.
175
175
 
176
- ## Hybrid mode / deferred validation
177
-
178
- > **Requires `checkoutMode: 'live'`** — `liveValidation` is ignored when `checkoutMode` is `'worktree'` (the default).
179
-
180
- Hybrid mode lets you run *coding* work items (implement, fix, docs, …) in isolated worktrees while keeping *validation* work items (build-and-test, test, verify, …) serialized in live checkout — the setup where build caches, native toolchains, and test infrastructure only exist in one canonical checkout.
181
-
182
- ### Full flow
183
-
184
- 1. **Coding WI dispatched** — because `liveValidation.type` is set and this WI's type is *not* the validation type, `resolveCheckoutMode(project, workItem.type)` returns `'worktree'`. The engine creates an isolated worktree as normal, runs the agent, and pushes a PR branch.
185
- 2. **Coding WI completes** — if `liveValidation.autoDispatch: true`, the lifecycle hook (`engine/lifecycle.js`) auto-creates a validation WI of type `liveValidation.type` targeting the same PR branch.
186
- 3. **Validation WI dispatched** — `resolveCheckoutMode(project, 'build-and-test')` returns `'live'` (matches `liveValidation.type`). The engine runs `prepareLiveCheckout` in the operator's canonical checkout, which checks out the coding WI's branch in-place and runs the validation agent.
187
- 4. **Validation WI completes** — `restoreLiveCheckoutAtDispatchEnd` checks the operator's tree back to the original ref.
188
-
189
- ### Config snippet
190
-
191
- ```jsonc
192
- {
193
- "projects": [{
194
- "name": "android-aosp",
195
- "localPath": "/home/yemi/aosp",
196
- "checkoutMode": "live",
197
- "liveValidation": {
198
- "type": "build-and-test",
199
- "autoDispatch": true
200
- }
201
- }]
202
- }
203
- ```
204
-
205
- Configure via Dashboard → Settings → Projects → **Live validation (deferred build/test)** section. The section is greyed-out when `checkoutMode` is not `'live'`.
206
-
207
- ### Agent behavior contract
208
-
209
- When a coding agent is dispatched on a project with `liveValidation.autoDispatch: true`, the agent's playbook includes a note that inline build/test runs are not required:
210
-
211
- > Skip inline build and test verification steps — a separate validation dispatch will run these in the live checkout after this coding WI completes. Push the branch and report success; the validation WI handles build/test confirmation.
212
-
213
- This prevents coding agents (running in isolated worktrees) from attempting builds that may fail due to missing native toolchains, caches, or environment variables only present in the canonical checkout.
214
-
215
- ### Migration guidance
216
-
217
- If your project is on `checkoutMode: live` and you want parallel coding WIs, add:
218
-
219
- ```jsonc
220
- "liveValidation": { "type": "build-and-test", "autoDispatch": true }
221
- ```
222
-
223
- No other config changes needed. The engine automatically:
224
- - Dispatches coding WIs into isolated worktrees (escaping the live-checkout cap of 1)
225
- - Dispatches validation WIs serially in live checkout (preserving your build environment)
226
- - Auto-creates validation WIs after each coding WI's PR is pushed
227
-
228
- To opt back out, clear `liveValidation` from the project config (or set to `null` via Dashboard → Settings).
229
-
230
176
  ## Non-goals
231
177
 
232
178
  Live-checkout mode is deliberately small. These are NOT supported and will not be added:
@@ -86,7 +86,7 @@ defaults (`WORKSPACE_MANIFEST_DEFAULTS` in `engine/shared.js`):
86
86
 
87
87
  Enforcement helpers (also in `engine/shared.js`): `validateWorkspaceManifest`,
88
88
  `resolveAgentManifest`, `agentCanUseRepo`, `agentCanUseTool`, `agentCanFetchUrl`,
89
- `agentMemoryScope`, `mergeManifestAllowedTools`. Today the engine actively enforces (a) the
89
+ `mergeManifestAllowedTools`. Today the engine actively enforces (a) the
90
90
  **repo gate** at dispatch time in `engine.js spawnAgent`
91
91
  (`FAILURE_CLASS.WORKSPACE_MANIFEST_REPO`, non-retryable) and (b) the **tool merge** into the
92
92
  runtime `--allowedTools` flag at spawn time. Defaults are permissive — an agent with no
@@ -31,7 +31,7 @@ A manifest lives on each agent definition under `workspace_manifest`. All fields
31
31
  | `allowed_tools` | `string[]` \| `null` | `null` (permissive) | Canonical tool-name whitelist (e.g. `Edit`, `Read`, `Bash`). Empty array = deny all. Case-sensitive. Merged into the runtime adapter's `--allowedTools` flag at spawn time. |
32
32
  | `allowed_repos` | `string[]` \| `null` | `null` (permissive) | Canonical PR-scope (`github:owner/repo`, `ado:org/proj/repo`) or bare `owner/repo`. Empty array = deny all. Case-insensitive. Enforced at dispatch time — out-of-scope dispatch fails with `failure_class: 'workspace-manifest-repo-forbidden'`. |
33
33
  | `allowed_external_urls` | `string[]` \| `null` | `null` (permissive) | Host allow-list for `web_fetch` / `web_search`. Bare host (`github.com`) = exact match; `*.example.com` = wildcard subdomain **and** apex. Empty array = deny all. Currently advisory — see "Enforcement points" below. |
34
- | `memory_scope` | `'private'` \| `'shared'` \| `'read-only-shared'` | `'shared'` | What slice of team knowledge the agent can read/write. Currently exposed via `shared.agentMemoryScope(agent)` for callers that want to gate inbox writes; full enforcement at the consolidation layer is future work. |
34
+ | `memory_scope` | `'private'` \| `'shared'` \| `'read-only-shared'` | `'shared'` | What slice of team knowledge the agent can read/write. Full enforcement at the consolidation layer is future work. |
35
35
 
36
36
  ## Default behaviour & backward compatibility
37
37
 
@@ -57,21 +57,20 @@ A config that never mentions `workspace_manifest` produces the exact same dispat
57
57
  | **Spawn — tool merge** | `engine.js spawnAgent()` → `_buildAgentSpawnFlags(..., allowedTools)` | `shared.mergeManifestAllowedTools(claudeConfig.allowedTools, manifest.allowed_tools)` produces the intersection of the runtime baseline and the manifest list. Result is passed as `--allowedTools <csv>` to Claude / Copilot / Codex, so the CLI itself enforces the narrowed surface. Empty manifest list (`[]`) = deny-all. Same merge runs on the steering-resume codepath. |
58
58
  | **Agent context** | Future work (playbook.js) | Manifest can be surfaced into the agent prompt so the agent sees its declared scope. Today, `shared.resolveAgentManifest(agent)` returns the resolved struct any caller can read. |
59
59
  | **URL fetch** | Advisory today | `allowed_external_urls` is validated at config load. No runtime helper function — a future runtime adapter hook can wire URL-fetch intercepts against the manifest. |
60
- | **Memory scope** | Advisory today | `shared.agentMemoryScope(agent)` is available for consolidation / playbook / inbox callers. Semantics: `private` = agent only sees its own `knowledge/agents/<id>.md`; `shared` = full team knowledge (current default); `read-only-shared` = reads shared memory but should not write inbox/notes. |
60
+ | **Memory scope** | Advisory today | `memory_scope` field is stored in the manifest (`private` = agent only sees its own `knowledge/agents/<id>.md`; `shared` = full team knowledge (current default); `read-only-shared` = reads shared memory but should not write inbox/notes). No runtime enforcement helper — full enforcement at the consolidation layer is future work. |
61
61
 
62
62
  ## Helpers (in `engine/shared.js`)
63
63
 
64
64
  ```js
65
65
  const { MEMORY_SCOPES, WORKSPACE_MANIFEST_DEFAULTS,
66
66
  validateWorkspaceManifest, resolveAgentManifest,
67
- agentCanUseRepo, agentMemoryScope,
67
+ agentCanUseRepo,
68
68
  mergeManifestAllowedTools, formatManifestRejection } = require('./engine/shared');
69
69
  ```
70
70
 
71
71
  - `validateWorkspaceManifest(manifest)` → `{ ok, errors }`. `null`/`undefined` is valid (uses defaults).
72
72
  - `resolveAgentManifest(agent, config?)` → fresh copy of `WORKSPACE_MANIFEST_DEFAULTS` overlaid with the agent's `workspace_manifest`. Malformed manifest silently falls back to defaults; surface the error via `validateWorkspaceManifest` at config-load time.
73
73
  - `agentCanUseRepo(agent, projectOrString)` → `bool`. Accepts a project object or a string identifier (`github:owner/repo`, `ado:org/proj/repo`, or bare `owner/repo`). Case-insensitive.
74
- - `agentMemoryScope(agent)` → one of `MEMORY_SCOPES`. Unknown values fall back to `'shared'`.
75
74
  - `mergeManifestAllowedTools(baselineCsv, manifestArray)` → merged CSV. Intersection semantics: `null` manifest = baseline unchanged; empty array = deny-all; empty baseline + manifest = manifest as ceiling.
76
75
  - `formatManifestRejection({ agentId, kind, target, allowed })` → structured human-readable rejection string used by the dispatch repo gate.
77
76
 
@@ -77,20 +77,7 @@ const FEATURES = {
77
77
  default: true,
78
78
  addedIn: '0.1.1916',
79
79
  requiredCcRuntime: 'copilot',
80
- },
81
- // prdReadsFromSql — Phase 10 step 3 read-flip. When ON, getPrdInfo sources its
82
- // PRD list (existingPrds / verifyPrsByPlan / allPrdItems) from the SQL mirror
83
- // (prds/prd_items/prd_verify_prs) after reconciling it from disk, instead of
84
- // scanning prd/*.json directly. JSON stays canonical and the dual-write keeps
85
- // SQL in sync; reconciliation catches PRDs written outside the chokepoint. The
86
- // two code paths are proven output-equivalent by db-phase10-read-flip.test.js.
87
- // Reversible: set `features.prdReadsFromSql: false` to fall back to the file
88
- // scan instantly. Temporary migration gate — remove once the read-flip soaks.
89
- 'prdReadsFromSql': {
90
- description: 'Source the dashboard PRD/plan read (getPrdInfo) from the SQL mirror instead of scanning prd/*.json. Reversible; the two paths are output-equivalent (Phase 10 step 3).',
91
- default: true,
92
- addedIn: '0.1.2090',
93
- expires: '2026-12-01',
80
+ expires: '2026-09-01',
94
81
  },
95
82
  // prdJoinFromFk — Phase 10 step 4.3 render-join flip. When ON, getPrdInfo
96
83
  // resolves each PRD item to its work item via the stable SQL FK
@@ -99,8 +86,9 @@ const FEATURES = {
99
86
  // archived PRD share a feature id (footgun #7). PURELY ADDITIVE: the FK lookup
100
87
  // is tried first and falls back to the exact legacy id match for any row not
101
88
  // yet stamped, when the flag is OFF, or when SQL is unavailable — so flipping
102
- // it off (or a NULL FK) reproduces current behavior byte-for-byte. Requires
103
- // prdReadsFromSql (the FK only exists in SQL). Temporary migration gate.
89
+ // it off (or a NULL FK) reproduces current behavior byte-for-byte. The FK
90
+ // lives only in SQL (unconditional read source since Phase 10 step 3 was
91
+ // completed). Temporary migration gate.
104
92
  'prdJoinFromFk': {
105
93
  description: 'Resolve the dashboard WI↔PRD-item join via the SQL FK (work_items.prd_item_id) instead of the feature-id string match. Additive with a string-match fallback; reversible (Phase 10 step 4.3).',
106
94
  default: true,
package/engine/github.js CHANGED
@@ -1935,6 +1935,9 @@ module.exports = {
1935
1935
  // Exported for testing
1936
1936
  isGitHub,
1937
1937
  getRepoSlug,
1938
+ getConfiguredGitHubAuthorLogins, // exported for testing (W-mqyn73pa000333c4)
1939
+ _commentKey, // exported for testing (W-mqyn73pa000333c4)
1940
+ _entryCommentKey, // exported for testing (W-mqyn73pa000333c4)
1938
1941
  isSlugInBackoff,
1939
1942
  recordSlugFailure,
1940
1943
  resetSlugBackoff,
@@ -60,6 +60,29 @@ function checkPlanCompletion(meta, config) {
60
60
  const workItemById = {};
61
61
  for (const w of planItems) { if (w.id) workItemById[w.id] = w; }
62
62
 
63
+ // Self-heal stale completion. checkPlanCompletion only ever ADVANCED a plan to
64
+ // `completed` — it never reverted. So a PRD that was completed and then gained
65
+ // a late item (e.g. a build-fix appended post-completion) or had an item
66
+ // regress / lose its WI got stuck `completed` forever: the materializer SKIPS
67
+ // `completed` PRDs (engine.js#materializePlansAsWorkItems), so the new item
68
+ // never materializes, and the gates below return early without clearing the
69
+ // flag — a permanent "completed PRD with a missing item" (the P-mqyp0006 /
70
+ // "Fix PR #341 failing build" incident). Re-open such a plan so the
71
+ // materializer recreates the missing WI and the normal completion flow resumes.
72
+ // Idempotent: once reverted (status active, flag cleared) the guard no-ops, so
73
+ // it cannot flap on subsequent scans while the item is genuinely incomplete.
74
+ const reopenStaleCompletedPlan = (reason) => {
75
+ if (plan.status !== PLAN_STATUS.COMPLETED && !plan._completionNotified) return false;
76
+ log('warn', `Plan ${planFile}: re-opening stale completed PRD — ${reason}`);
77
+ mutateJsonFileLocked(planPath, (data) => {
78
+ if (data.status === PLAN_STATUS.COMPLETED) data.status = PLAN_STATUS.ACTIVE;
79
+ delete data.completedAt;
80
+ data._completionNotified = false;
81
+ return data;
82
+ });
83
+ return true;
84
+ };
85
+
63
86
  // Check 1: every feature must have a work item (materialized)
64
87
  // Fallback: also accept features marked done directly in the PRD JSON (resolved externally)
65
88
  const unmaterialized = [...planFeatureIds].filter(id => {
@@ -69,19 +92,27 @@ function checkPlanCompletion(meta, config) {
69
92
  });
70
93
  if (unmaterialized.length > 0) {
71
94
  log('info', `Plan ${planFile}: ${unmaterialized.length}/${planFeatureIds.size} feature(s) not yet materialized as work items: ${unmaterialized.join(', ')}`);
95
+ reopenStaleCompletedPlan(`${unmaterialized.length} unmaterialized item(s): ${unmaterialized.join(', ')}`);
72
96
  return;
73
97
  }
74
98
 
75
99
  // Check 2: every feature must be in a terminal state (done, failed, or cancelled).
76
100
  // Failed/cancelled items are unrecoverable — waiting on them blocks the plan indefinitely.
101
+ // P-a2b4c6d8: when a work item EXISTS, use ONLY the work item's status — do NOT fall
102
+ // through to the PRD item's own status field as a shortcut. If syncPrdItemStatus stamped
103
+ // the PRD item 'done' ahead of the actual WI completing (race / premature write), the old
104
+ // fallback incorrectly passed the gate and triggered early verify WI creation. The PRD item
105
+ // status is only a valid fallback when NO work item has been materialized for that feature
106
+ // (externally-resolved items that were never dispatched).
77
107
  const notTerminal = [...planFeatureIds].filter(id => {
78
108
  const w = workItemById[id];
79
- if (w && PLAN_TERMINAL_STATUSES.has(w.status)) return false;
109
+ if (w) return !PLAN_TERMINAL_STATUSES.has(w.status); // WI exists: trust WI status only
80
110
  const prdItem = (plan.missing_features || []).find(f => f.id === id);
81
111
  return !(prdItem && PLAN_TERMINAL_STATUSES.has(prdItem.status));
82
112
  });
83
113
  if (notTerminal.length > 0) {
84
114
  log('info', `Plan ${planFile}: waiting for ${notTerminal.length}/${planFeatureIds.size} item(s) to reach terminal state: ${notTerminal.join(', ')}`);
115
+ reopenStaleCompletedPlan(`${notTerminal.length} non-terminal item(s): ${notTerminal.join(', ')}`);
85
116
  return;
86
117
  }
87
118
 
@@ -815,6 +846,22 @@ function stampPrdItemWorkItemId(itemId, sourcePlan) {
815
846
  } catch (err) { log('warn', `stampPrdItemWorkItemId: ${err.message}`); }
816
847
  }
817
848
 
849
+ function stampPrdItemPrUrl(itemId, sourcePlan, prUrl) {
850
+ if (!itemId || !sourcePlan || !prUrl) return;
851
+ try {
852
+ const fpath = path.join(PRD_DIR, sourcePlan);
853
+ if (!fs.existsSync(fpath)) return;
854
+ const plan = safeJsonNoRestore(fpath);
855
+ const feature = plan?.missing_features?.find(f => f.id === itemId);
856
+ if (!feature || feature.pr_url === prUrl) return;
857
+ mutateJsonFileLocked(fpath, (fresh) => {
858
+ const f = fresh?.missing_features?.find(x => x.id === itemId);
859
+ if (f && f.pr_url !== prUrl) f.pr_url = prUrl;
860
+ return fresh;
861
+ }, { skipWriteIfUnchanged: true });
862
+ } catch (err) { log('warn', `stampPrdItemPrUrl: ${err?.message || err}`); }
863
+ }
864
+
818
865
  // ─── PRD Backward-Scan Reconciliation (#929, #984) ─────────────────────────
819
866
  // Proactive counterpart to syncPrdItemStatus. Scans all active PRDs and:
820
867
  // 1. Promotes "missing" items to "updated" when a done work item already exists (#929)
@@ -1088,6 +1135,7 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
1088
1135
  url: prUrl,
1089
1136
  prdItems: meta?.item?.id ? [meta.item.id] : [],
1090
1137
  sourcePlan: meta?.item?.sourcePlan || '',
1138
+ prdItemId: meta?.item?.id || '',
1091
1139
  itemType: meta?.item?.itemType || '',
1092
1140
  contextOnly: shouldSyncPrAsContextOnly(meta),
1093
1141
  };
@@ -5834,6 +5882,25 @@ function syncPrdFromPrs(config) {
5834
5882
  if (totalReconciled > 0) {
5835
5883
  log('info', `PR sync: reconciled ${totalReconciled} work item(s) to done`);
5836
5884
  }
5885
+
5886
+ // Stamp pr_url onto PRD items for every PR that has prdItems[] + a URL (P-b3c2d4e5).
5887
+ // This is separate from the done-reconcile loop above — we stamp pr_url as soon as
5888
+ // we know the PR exists, regardless of merged/open status.
5889
+ for (const pr of allPrs) {
5890
+ if (!pr?.url || !Array.isArray(pr.prdItems) || pr.prdItems.length === 0) continue;
5891
+ for (const itemId of pr.prdItems) {
5892
+ if (!itemId) continue;
5893
+ let sourcePlan = pr.sourcePlan;
5894
+ if (!sourcePlan) {
5895
+ for (const project of allProjects) {
5896
+ const items = safeJsonArr(shared.projectWorkItemsPath(project));
5897
+ const wi = items.find(w => w.id === itemId);
5898
+ if (wi?.sourcePlan) { sourcePlan = wi.sourcePlan; break; }
5899
+ }
5900
+ }
5901
+ if (sourcePlan) stampPrdItemPrUrl(itemId, sourcePlan, pr.url);
5902
+ }
5903
+ }
5837
5904
  } catch (err) {
5838
5905
  // Non-fatal — log and continue
5839
5906
  try { log('warn', `syncPrdFromPrs error: ${err?.message || err}`); } catch { /* engine not available */ }
@@ -6156,6 +6223,44 @@ function pruneScopeMismatchDuplicatePrs(config) {
6156
6223
  return { pruned, scanned };
6157
6224
  }
6158
6225
 
6226
+ // Repair helper: collapse prNumber duplicates across all project-scoped and
6227
+ // the central pull-requests file. Called once per reconcile cycle alongside
6228
+ // pruneScopeMismatchDuplicatePrs so accumulated duplicates are cleaned up
6229
+ // without requiring a one-off migration.
6230
+ function collapseAllDuplicatePrRecords(config) {
6231
+ config = config || getConfig();
6232
+ const projects = shared.getProjects(config) || [];
6233
+ let collapsed = 0;
6234
+ let scanned = 0;
6235
+
6236
+ for (const project of projects) {
6237
+ if (!project || !project.name) continue;
6238
+ const prPath = projectPrPath(project);
6239
+ try {
6240
+ const result = shared.collapseDuplicatePrRecords(prPath, { project });
6241
+ collapsed += result.collapsed || 0;
6242
+ scanned++;
6243
+ } catch (err) {
6244
+ log('warn', `collapseAllDuplicatePrRecords: failed for project=${project.name}: ${err?.message || err}`);
6245
+ }
6246
+ }
6247
+
6248
+ // Central file (no project context — normalization uses URL-only scope).
6249
+ const centralPath = path.join(MINIONS_DIR, 'pull-requests.json');
6250
+ try {
6251
+ const result = shared.collapseDuplicatePrRecords(centralPath, {});
6252
+ collapsed += result.collapsed || 0;
6253
+ scanned++;
6254
+ } catch (err) {
6255
+ log('warn', `collapseAllDuplicatePrRecords: failed for central file: ${err?.message || err}`);
6256
+ }
6257
+
6258
+ if (collapsed > 0) {
6259
+ log('info', `[pull-requests] collapseAllDuplicatePrRecords: collapsed ${collapsed} duplicate(s) across ${scanned} scope(s)`);
6260
+ }
6261
+ return { collapsed, scanned };
6262
+ }
6263
+
6159
6264
  // M003 — After a coding WI completes successfully, auto-dispatch a live-validation
6160
6265
  // WI when project.liveValidation.autoDispatch === true and the completed item is
6161
6266
  // a coding WI (not the validation type itself). Skips if the coding WI has no PR.
@@ -6237,6 +6342,7 @@ module.exports = {
6237
6342
  reconcilePrdStatuses,
6238
6343
  syncPrsFromOutput,
6239
6344
  pruneScopeMismatchDuplicatePrs,
6345
+ collapseAllDuplicatePrRecords,
6240
6346
  updatePrAfterReview,
6241
6347
  updatePrAfterFix,
6242
6348
  updatePrAfterFixError,
@@ -6304,6 +6410,7 @@ module.exports = {
6304
6410
  normalizePrFixBranchName,
6305
6411
  stampWiPrRef,
6306
6412
  stampPrdItemWorkItemId,
6413
+ stampPrdItemPrUrl,
6307
6414
  // M003 — auto-dispatch live-validation WI after coding WI completion.
6308
6415
  autoDispatchLiveValidationWi,
6309
6416
  };
@@ -394,6 +394,11 @@ const PLAYBOOK_OPTIONAL_VARS = new Set([
394
394
  // Both are optional; playbooks that don't reference them receive empty strings.
395
395
  'from_agent',
396
396
  'from_conclusion',
397
+ // P-mqyp0008v022w3x4 (SHERLOC) — prior explore WI resultSummary injected into
398
+ // fix dispatches when a completed explore WI references the fix WI. Empty string
399
+ // when no prior explore WI exists; the {{#prior_explore_context}} conditional
400
+ // in fix.md renders the section only when non-empty.
401
+ 'prior_explore_context',
397
402
  ]);
398
403
 
399
404
  const PLAYBOOK_REQUIRED_VARS = {
@@ -1011,11 +1016,11 @@ function renderPlaybook(type, vars) {
1011
1016
  // ─── Playbook Section Validator ──────────────────────────────────────────────
1012
1017
 
1013
1018
  // Required structural section patterns — warn (do not throw) when absent.
1014
- // NOTE: '## Tools' / '## Constraints' are NOT included because none of the
1015
- // production playbooks use those headers. Only '## Your Task' is universally
1016
- // present — it is injected by renderPlaybook via the playbook template.
1019
+ // Pluralisation: /^## Tools?\b/m matches both '## Tool' and '## Tools'.
1017
1020
  const _REQUIRED_PROMPT_SECTIONS = [
1018
- { pattern: /^## Your Task\b/m, label: '## Your Task' },
1021
+ { pattern: /^## Your Task\b/m, label: '## Your Task' },
1022
+ { pattern: /^## Tools?\b/m, label: '## Tools' },
1023
+ { pattern: /^## Constraints\b/m, label: '## Constraints' },
1019
1024
  ];
1020
1025
 
1021
1026
  /**
package/engine/queries.js CHANGED
@@ -2077,8 +2077,7 @@ function getWorkItems(config, opts) {
2077
2077
 
2078
2078
  // ── PRD Progress ────────────────────────────────────────────────────────────
2079
2079
 
2080
- // Module-level caches for getPrdInfo() — avoids re-reading unchanged PRD files
2081
- const _prdFileCache = new Map(); // filePath → { mtimeMs, plan }
2080
+ // Module-level caches for getPrdInfo() — avoids re-reading unchanged PRD data
2082
2081
  let _prdDirMtimes = { prd: 0, archive: 0 }; // directory mtimes to detect new/deleted files
2083
2082
  let _prdResultCache = null; // cached final result
2084
2083
  let _prdResultInputHash = ''; // hash of all input mtimes to detect any change
@@ -2141,31 +2140,22 @@ function getPrdInfo(config) {
2141
2140
  const verifyPrsByPlan = {};
2142
2141
  let latestStat = null;
2143
2142
 
2144
- // Check if directory listings need refresh
2145
- const dirsChanged = prdDirMtime !== _prdDirMtimes.prd || archiveDirMtime !== _prdDirMtimes.archive;
2143
+ // Check if directory listings need refresh (used by mtime hash)
2146
2144
  _prdDirMtimes = { prd: prdDirMtime, archive: archiveDirMtime };
2147
2145
 
2148
- // Scan active PRDs and archived PRDs (completed PRDs still need to show progress)
2149
- const planDirs = [
2150
- { dir: PRD_DIR, archived: false },
2151
- { dir: path.join(PRD_DIR, 'archive'), archived: true },
2152
- ];
2153
-
2154
2146
  // Phase 10 step 4.3 — FK-based WI↔PRD-item join scaffolding. `prdItemIdByKey`
2155
2147
  // maps `${physicalArchived} ${filename} ${feature_id}` → prd_items.id so each
2156
- // PRD item carries its stable surrogate id; built below once the SQL-read
2157
- // decision is known. Declared here so processPrd's closure can stamp
2158
- // `_prdItemId` at push time. Null when the FK join is off / SQL unavailable
2159
- // the per-item join falls back to the legacy feature-id string match.
2148
+ // PRD item carries its stable surrogate id. Declared here so processPrd's
2149
+ // closure can stamp `_prdItemId` at push time. Null when the FK join is off /
2150
+ // SQL unavailable the per-item join falls back to the legacy feature-id
2151
+ // string match.
2160
2152
  let useFkJoin = false;
2161
2153
  try { useFkJoin = require('./features').isFeatureOn('prdJoinFromFk', config); } catch { /* fall back to string join */ }
2162
2154
  let prdItemIdByKey = null;
2163
2155
 
2164
- // Phase 10 step 3 (read-flip) — both the disk scan and the SQL-mirror source
2165
- // funnel each PRD through this one processor, so the consumed shape
2156
+ // Phase 10 step 3 (read-flip complete) — both disk-write and SQL-mirror sources
2157
+ // funnel each PRD through this one processor so the consumed shape
2166
2158
  // (existingPrds / verifyPrsByPlan / allPrdItems) is identical by construction.
2167
- // The only difference is where `plan` + `mtimeMs` come from. Equivalence of the
2168
- // two paths is proven by test/unit/db-phase10-read-flip.test.js.
2169
2159
  const processPrd = (pf, archived, plan, mtimeMs) => {
2170
2160
  if (!plan || !plan.missing_features) return;
2171
2161
  // Phase 10 step 4.2: archived-ness is the FLAG, not the directory. `archived`
@@ -2215,14 +2205,12 @@ function getPrdInfo(config) {
2215
2205
  }
2216
2206
  };
2217
2207
 
2218
- let useSqlReads = false;
2219
- try { useSqlReads = require('./features').isFeatureOn('prdReadsFromSql', config); } catch { /* fall back to disk */ }
2220
-
2221
2208
  // Build the feature → prd_items.id map before the read loop so processPrd can
2222
- // stamp _prdItemId. Only when the FK join is on AND we're SQL-sourced (the FK
2223
- // lives only in SQL). Best-effort: any failure leaves prdItemIdByKey null and
2224
- // the join silently falls back to the legacy feature-id string match.
2225
- if (useFkJoin && useSqlReads) {
2209
+ // stamp _prdItemId. SQL is the unconditional source (Phase 10 step 3 complete),
2210
+ // so the FK join just needs useFkJoin. Best-effort: any failure leaves
2211
+ // prdItemIdByKey null and the join silently falls back to the legacy
2212
+ // feature-id string match.
2213
+ if (useFkJoin) {
2226
2214
  try {
2227
2215
  const db = require('./db').getDb();
2228
2216
  const map = new Map();
@@ -2233,65 +2221,24 @@ function getPrdInfo(config) {
2233
2221
  } catch { prdItemIdByKey = null; }
2234
2222
  }
2235
2223
 
2236
- if (useSqlReads) {
2237
- // SQL source: reconcile the mirror from disk (catches PRDs written outside
2238
- // the dual-write chokepoint plan-to-prd agent direct writes, archive
2239
- // moves, deletes), then read each PRD's content from SQL. Files still live on
2240
- // disk in this phase, so stat them for the mtime fields + `age`.
2224
+ // Phase 10 step 3 (read-flip complete) — SQL mirror is the unconditional
2225
+ // source. reconcilePrdsFromDisk() catches PRDs written outside the dual-write
2226
+ // chokepoint (plan-to-prd agent direct writes, archive moves, deletes). Files
2227
+ // still live on disk in this phase, so stat them for mtime fields + `age`.
2228
+ const prdStore = require('./prd-store');
2229
+ prdStore.reconcilePrdsFromDisk();
2230
+ for (const { filename, archived, plan } of prdStore.listPrdRows()) {
2241
2231
  try {
2242
- const prdStore = require('./prd-store');
2243
- prdStore.reconcilePrdsFromDisk();
2244
- for (const { filename, archived, plan } of prdStore.listPrdRows()) {
2245
- try {
2246
- const dir = archived ? path.join(PRD_DIR, 'archive') : PRD_DIR;
2247
- let mtimeMs = 0;
2248
- try {
2249
- const stat = fs.statSync(path.join(dir, filename));
2250
- mtimeMs = stat.mtimeMs;
2251
- if (!latestStat || stat.mtimeMs > latestStat.mtimeMs) latestStat = stat;
2252
- } catch { /* file may have just moved/deleted between reconcile and read */ }
2253
- processPrd(filename, archived, plan, mtimeMs);
2254
- } catch { /* optional */ }
2255
- }
2256
- } catch {
2257
- // Hard SQL failure → fall back to the disk scan so the dashboard never
2258
- // goes blank on a transient DB problem.
2259
- useSqlReads = false;
2260
- }
2261
- }
2262
-
2263
- if (!useSqlReads) {
2264
- for (const { dir, archived } of planDirs) {
2232
+ const dir = archived ? path.join(PRD_DIR, 'archive') : PRD_DIR;
2233
+ let mtimeMs = 0;
2265
2234
  try {
2266
- const planFiles = fs.readdirSync(dir).filter(f => f.endsWith('.json')).sort();
2267
- for (const pf of planFiles) {
2268
- try {
2269
- const filePath = path.join(dir, pf);
2270
- const stat = fs.statSync(filePath);
2271
- if (!latestStat || stat.mtimeMs > latestStat.mtimeMs) latestStat = stat;
2272
-
2273
- // Per-file mtime cache: only re-read files that changed
2274
- const cached = _prdFileCache.get(filePath);
2275
- let plan;
2276
- if (cached && cached.mtimeMs === stat.mtimeMs) {
2277
- plan = cached.plan;
2278
- } else {
2279
- plan = readJsonNoRestore(filePath);
2280
- _prdFileCache.set(filePath, { mtimeMs: stat.mtimeMs, plan });
2281
- }
2282
- processPrd(pf, archived, plan, stat.mtimeMs);
2283
- } catch { /* optional */ }
2284
- }
2285
- // Clean stale entries from file cache when dirs changed
2286
- if (dirsChanged) {
2287
- for (const cachedPath of _prdFileCache.keys()) {
2288
- if (cachedPath.startsWith(dir) && !fs.existsSync(cachedPath)) _prdFileCache.delete(cachedPath);
2289
- }
2290
- }
2291
- } catch { /* optional */ }
2292
- }
2235
+ const stat = fs.statSync(path.join(dir, filename));
2236
+ mtimeMs = stat.mtimeMs;
2237
+ if (!latestStat || stat.mtimeMs > latestStat.mtimeMs) latestStat = stat;
2238
+ } catch { /* file may have just moved/deleted between reconcile and read */ }
2239
+ processPrd(filename, archived, plan, mtimeMs);
2240
+ } catch { /* optional */ }
2293
2241
  }
2294
-
2295
2242
  if (allPrdItems.length === 0) return { progress: null, status: null };
2296
2243
 
2297
2244
  const items = allPrdItems;
@@ -2331,7 +2278,7 @@ function getPrdInfo(config) {
2331
2278
  // objects we already loaded, keyed by wi.id (first-wins, matching allWiById).
2332
2279
  // Best-effort: null map → the join falls back to the legacy string match.
2333
2280
  let wiByPrdItemId = null;
2334
- if (useFkJoin && useSqlReads && prdItemIdByKey) {
2281
+ if (useFkJoin && prdItemIdByKey) {
2335
2282
  try {
2336
2283
  const db = require('./db').getDb();
2337
2284
  const fkByWiId = new Map();
@@ -2437,6 +2384,20 @@ function getPrdInfo(config) {
2437
2384
  }
2438
2385
  }
2439
2386
 
2387
+ // Fallback (P-b6c8d0e2): PR records stamped with prdItemId/sourcePlan at sync
2388
+ // time. When the standard prLinks chain doesn't cover a PRD item (e.g. the WI
2389
+ // id differs from the PRD feature id, or prLinks is stale), use the PR record's
2390
+ // own prdItemId field to ensure the PRD view shows the PR.
2391
+ for (const pr of allPrs) {
2392
+ const prdItemIdFromPr = typeof pr.prdItemId === 'string' ? pr.prdItemId : '';
2393
+ if (!prdItemIdFromPr || !prdItemIdSet.has(prdItemIdFromPr)) continue;
2394
+ if (prdToPr[prdItemIdFromPr] && prdToPr[prdItemIdFromPr].length > 0) continue; // already covered
2395
+ if (!prdToPr[prdItemIdFromPr]) prdToPr[prdItemIdFromPr] = [];
2396
+ const url = buildPrUrlFromId(pr.id, pr, projects);
2397
+ prdToPr[prdItemIdFromPr].push({ id: pr.id, url, title: pr.title || '', status: pr.status || PR_STATUS.ACTIVE, _project: pr._project || '' });
2398
+ }
2399
+
2400
+
2440
2401
  // PRD JSON status is the source of truth — kept in sync with work item by syncPrdItemStatus.
2441
2402
  // Map from PRD JSON values to display values (pending → missing for undispatched items)
2442
2403
  // Augment each item with execution metadata from the work item.
@@ -2523,7 +2484,6 @@ function getPrdInfo(config) {
2523
2484
 
2524
2485
  /** Reset PRD info cache — exported for testing */
2525
2486
  function resetPrdInfoCache() {
2526
- _prdFileCache.clear();
2527
2487
  _prdDirMtimes = { prd: 0, archive: 0 };
2528
2488
  _prdResultCache = null;
2529
2489
  _prdResultInputHash = '';
package/engine/shared.js CHANGED
@@ -1782,6 +1782,23 @@ function _tryRouteMutateToSql(filePath, mutateFn, onWrote) {
1782
1782
  if (smallRoute) {
1783
1783
  if (!fpNorm.endsWith('/engine/' + baseName)) return null;
1784
1784
  const store = require('./small-state-store');
1785
+ // Pre-warm the SQLite DB BEFORE acquiring the file lock. On the very first
1786
+ // call from a process (or after a test-isolation MINIONS_TEST_DIR swap),
1787
+ // getDb() opens the DB file and runs migrations — which can take tens of
1788
+ // milliseconds on a loaded box. When 5+ concurrent processes all hit this
1789
+ // path simultaneously, whichever one wins the file lock holds it for the
1790
+ // entire DB-init duration. The other 4 processes each have only a 5-second
1791
+ // timeout window to acquire the lock; if DB init exceeds that, they throw
1792
+ // "Lock timeout" and exit 1 (observed in CI after opg#552 backport which
1793
+ // added a new test that increases system load during parallel runs).
1794
+ // Pre-warming here means getDb() returns the cached _db instantly when
1795
+ // called again inside the lock, so the lock is held only for the fast
1796
+ // SQL transaction + mirror write — not for DB initialization.
1797
+ try {
1798
+ require('./db').getDb();
1799
+ } catch (e) {
1800
+ throw new Error(`small-state-store: SQLite unavailable — ${e.message}`);
1801
+ }
1785
1802
  // Hold the JSON file lock across the SQL transaction AND the mirror
1786
1803
  // write. The SQL transaction itself is cross-process serialized via
1787
1804
  // BEGIN IMMEDIATE inside the SQL store, but the JSON mirror is a
@@ -2679,21 +2696,12 @@ const CHECKOUT_MODES = Object.freeze({ WORKTREE: 'worktree', LIVE: 'live' });
2679
2696
  function resolveCheckoutMode(project, workItemType) {
2680
2697
  if (!project || typeof project !== 'object') return CHECKOUT_MODES.WORKTREE;
2681
2698
  const canonical = project.checkoutMode;
2682
- // Resolve the effective mode: canonical field first, then legacy worktreeMode fallback.
2683
- let effectiveMode;
2684
- if (canonical === CHECKOUT_MODES.LIVE || canonical === CHECKOUT_MODES.WORKTREE) {
2685
- effectiveMode = canonical;
2686
- } else if (project.worktreeMode === 'live') {
2687
- effectiveMode = CHECKOUT_MODES.LIVE;
2688
- } else {
2689
- effectiveMode = CHECKOUT_MODES.WORKTREE;
2690
- }
2691
- // liveValidation without an effective live mode is a misconfiguration — warn and ignore.
2692
- if (project.liveValidation && effectiveMode !== CHECKOUT_MODES.LIVE) {
2699
+ // liveValidation without checkoutMode: 'live' is a misconfiguration warn and ignore.
2700
+ if (project.liveValidation && canonical !== CHECKOUT_MODES.LIVE) {
2693
2701
  log('warn', 'resolveCheckoutMode: liveValidation is set but checkoutMode is not "live" — liveValidation ignored',
2694
2702
  { projectName: project.name });
2695
2703
  }
2696
- if (effectiveMode === CHECKOUT_MODES.LIVE) {
2704
+ if (canonical === CHECKOUT_MODES.LIVE) {
2697
2705
  // Apply liveValidation routing when the block is present and workItemType is provided.
2698
2706
  if (project.liveValidation && workItemType !== undefined) {
2699
2707
  return workItemType === project.liveValidation.type
@@ -2702,6 +2710,11 @@ function resolveCheckoutMode(project, workItemType) {
2702
2710
  }
2703
2711
  return CHECKOUT_MODES.LIVE;
2704
2712
  }
2713
+ if (canonical === CHECKOUT_MODES.WORKTREE) return CHECKOUT_MODES.WORKTREE;
2714
+ // Legacy field fallback (only consulted when checkoutMode is absent/unknown).
2715
+ const legacy = project.worktreeMode;
2716
+ if (legacy === 'live') return CHECKOUT_MODES.LIVE;
2717
+ // legacy 'isolated' (and anything else) → the default worktree behavior.
2705
2718
  return CHECKOUT_MODES.WORKTREE;
2706
2719
  }
2707
2720
 
@@ -3643,8 +3656,14 @@ let _legacyCcModelMigrationLogged = false;
3643
3656
  function applyLegacyCcModelMigration(config, { logger = log } = {}) {
3644
3657
  if (!config || !config.engine || typeof config.engine !== 'object') return false;
3645
3658
  const e = config.engine;
3646
- if (e.defaultModel !== undefined && e.defaultModel !== null && e.defaultModel !== '') return false;
3647
- if (!(e.ccModel !== undefined && e.ccModel !== null && e.ccModel !== '')) return false;
3659
+ if (e.defaultModel !== undefined && e.defaultModel !== null && e.defaultModel !== '') {
3660
+ logger('info', 'legacy-cc-model-migration: no-op (0 records migrated), safe to schedule removal.');
3661
+ return false;
3662
+ }
3663
+ if (!(e.ccModel !== undefined && e.ccModel !== null && e.ccModel !== '')) {
3664
+ logger('info', 'legacy-cc-model-migration: no-op (0 records migrated), safe to schedule removal.');
3665
+ return false;
3666
+ }
3648
3667
  e.defaultModel = e.ccModel;
3649
3668
  if (!_legacyCcModelMigrationLogged) {
3650
3669
  _legacyCcModelMigrationLogged = true;
@@ -4860,12 +4879,6 @@ function agentCanUseRepo(agent, repoTarget) {
4860
4879
  return false;
4861
4880
  }
4862
4881
 
4863
- /** Return the agent's effective memory scope. Unknown values fall back to 'shared'. */
4864
- function agentMemoryScope(agent) {
4865
- const manifest = resolveAgentManifest(agent);
4866
- return MEMORY_SCOPES.includes(manifest.memory_scope) ? manifest.memory_scope : 'shared';
4867
- }
4868
-
4869
4882
  /**
4870
4883
  * Merge a manifest's `allowed_tools` list into the runtime adapter's existing
4871
4884
  * `allowedTools` CSV baseline. Returns the merged CSV string the runtime
@@ -7068,6 +7081,91 @@ function addPrLink(prId, itemId, { project = null, url = '', prNumber = null } =
7068
7081
  });
7069
7082
  }
7070
7083
 
7084
+ // Fields copied from "loser" duplicates into the winner when collapsing.
7085
+ // Additive (array-merge) fields are listed separately.
7086
+ const _DEDUP_MERGE_SCALAR_KEYS = [
7087
+ 'url', 'title', 'agent', 'branch', 'description', 'sourcePlan', 'itemType',
7088
+ 'mergedAt', 'closedAt', 'created', 'reviewStatus',
7089
+ 'minionsReview', '_automationFixCauses',
7090
+ ];
7091
+
7092
+ // Pick the "most complete" record from a list of duplicates.
7093
+ // Preference order:
7094
+ // 1. merged status (most terminal / most informative)
7095
+ // 2. record with a URL (enables live polling)
7096
+ // 3. record with more own keys (richer data)
7097
+ function _pickBestPrRecord(candidates) {
7098
+ if (candidates.length === 1) return candidates[0];
7099
+ const merged = candidates.filter(c => c && c.status === PR_STATUS.MERGED);
7100
+ const pool = merged.length > 0 ? merged : candidates.filter(Boolean);
7101
+ const withUrl = pool.filter(c => c.url);
7102
+ const final = withUrl.length > 0 ? withUrl : pool;
7103
+ return final.reduce((a, b) => Object.keys(b || {}).length > Object.keys(a || {}).length ? b : a);
7104
+ }
7105
+
7106
+ // Merge non-conflicting fields from a loser duplicate into the winner.
7107
+ // Scalar fields are filled when winner is empty; prdItems are union-merged.
7108
+ function _mergeDuplicatePrInto(winner, loser) {
7109
+ if (!winner || !loser) return;
7110
+ for (const key of _DEDUP_MERGE_SCALAR_KEYS) {
7111
+ if ((winner[key] == null || winner[key] === '') && loser[key] != null && loser[key] !== '') {
7112
+ winner[key] = loser[key];
7113
+ }
7114
+ }
7115
+ winner.prdItems = normalizePrLinkItems([...(winner.prdItems || []), ...(loser.prdItems || [])]);
7116
+ }
7117
+
7118
+ /**
7119
+ * P-e9f0a2b4 — One-time repair helper: collapse duplicate PR records that share
7120
+ * the same `prNumber` within a single `pull-requests.json` file.
7121
+ *
7122
+ * Call this at engine start for every project's PR file (and the central file).
7123
+ * Idempotent — no-op when no duplicates exist.
7124
+ *
7125
+ * Selection rule (matching the task spec):
7126
+ * 1. Prefer `merged` status over any other status.
7127
+ * 2. Among tied: prefer the record with a URL.
7128
+ * 3. Among tied: prefer the record with more fields (richer data).
7129
+ * Fields unique to the losers are merged into the winner:
7130
+ * • scalar fields: filled only when the winner's field is empty
7131
+ * • prdItems: union-merged
7132
+ *
7133
+ * @param {string} prPath Path to the pull-requests.json file.
7134
+ * @param {{ project?: object|null }} opts Optional project config for normalization.
7135
+ * @returns {{ collapsed: number }} Number of duplicate records removed.
7136
+ */
7137
+ function collapseDuplicatePrRecords(prPath, { project = null } = {}) {
7138
+ let collapsed = 0;
7139
+ mutatePullRequests(prPath, (prs) => {
7140
+ normalizePrRecords(prs, project);
7141
+ // Group by prNumber.
7142
+ const byNumber = new Map();
7143
+ for (const pr of prs) {
7144
+ if (!pr) continue;
7145
+ const num = getPrNumber(pr);
7146
+ if (num == null) continue;
7147
+ if (!byNumber.has(num)) byNumber.set(num, []);
7148
+ byNumber.get(num).push(pr);
7149
+ }
7150
+ // Identify loser records to remove (by object reference, not id, because
7151
+ // normalization may have changed multiple records to the same canonical id).
7152
+ const toRemove = new Set();
7153
+ for (const [, group] of byNumber) {
7154
+ if (group.length <= 1) continue;
7155
+ const best = _pickBestPrRecord(group);
7156
+ for (const dup of group) {
7157
+ if (!dup || dup === best) continue;
7158
+ _mergeDuplicatePrInto(best, dup);
7159
+ toRemove.add(dup);
7160
+ collapsed++;
7161
+ }
7162
+ }
7163
+ if (toRemove.size === 0) return prs;
7164
+ return prs.filter(pr => !toRemove.has(pr));
7165
+ });
7166
+ return { collapsed };
7167
+ }
7168
+
7071
7169
  /**
7072
7170
  * Canonical PR-producing work contract helper.
7073
7171
  *
@@ -7121,6 +7219,26 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
7121
7219
  skipped = true;
7122
7220
  return prs;
7123
7221
  }
7222
+ // P-e9f0a2b4 — When findPrRecord returns null but multiple records share the
7223
+ // same prNumber (e.g. cross-scope contamination or a project-config change
7224
+ // that shifted canonical IDs), collapse the duplicates before inserting.
7225
+ // Without this check, every subsequent upsert would append yet another record
7226
+ // because numberMatches.length > 1 forces findPrRecord to return null.
7227
+ if (!target && prNumber != null) {
7228
+ const numberMatches = prs.filter(pr => pr && !pr.userDeleted && getPrNumber(pr) === prNumber);
7229
+ if (numberMatches.length > 1) {
7230
+ const best = _pickBestPrRecord(numberMatches);
7231
+ const toRemoveIds = new Set(numberMatches.filter(p => p !== best).map(p => p.id));
7232
+ for (const dup of numberMatches) {
7233
+ if (dup === best) continue;
7234
+ _mergeDuplicatePrInto(best, dup);
7235
+ }
7236
+ for (let i = prs.length - 1; i >= 0; i--) {
7237
+ if (prs[i] && toRemoveIds.has(prs[i].id)) prs.splice(i, 1);
7238
+ }
7239
+ target = best; // update the surviving record instead of inserting new
7240
+ }
7241
+ }
7124
7242
  if (!target && typeof beforeInsert === 'function' && beforeInsert(prs, normalizedEntry) === false) {
7125
7243
  skipped = true;
7126
7244
  return prs;
@@ -7133,7 +7251,7 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
7133
7251
  target.id = canonicalId;
7134
7252
  if (prNumber != null) target.prNumber = prNumber;
7135
7253
  const targetWasAutoManaged = isAutoManagedPrRecord(target);
7136
- for (const key of ['url', 'title', 'description', 'agent', 'branch', 'reviewStatus', 'status', 'created', 'sourcePlan', 'itemType']) {
7254
+ for (const key of ['url', 'title', 'description', 'agent', 'branch', 'reviewStatus', 'status', 'created', 'sourcePlan', 'itemType', 'prdItemId']) {
7137
7255
  if (normalizedEntry[key] != null && normalizedEntry[key] !== '' && (target[key] == null || target[key] === '')) {
7138
7256
  target[key] = normalizedEntry[key];
7139
7257
  }
@@ -8969,7 +9087,6 @@ module.exports = {
8969
9087
  validateWorkspaceManifest,
8970
9088
  resolveAgentManifest,
8971
9089
  agentCanUseRepo,
8972
- agentMemoryScope,
8973
9090
  mergeManifestAllowedTools,
8974
9091
  formatManifestRejection,
8975
9092
  getProjects,
@@ -9021,6 +9138,7 @@ module.exports = {
9021
9138
  mergePrLinkItems, // exported for testing
9022
9139
  isContextOnlyPrRecord,
9023
9140
  upsertPullRequestRecord,
9141
+ collapseDuplicatePrRecords, // P-e9f0a2b4 — one-time repair helper
9024
9142
  isAutoManagedPrRecord, // W-mq5s5ttx000j7ab8-a — exported for engine + watch-plugin gate consolidation
9025
9143
  autoEnrollPrFromWorkItem,
9026
9144
  autoEnrollPrFromFixWorkItem,
package/engine.js CHANGED
@@ -168,7 +168,7 @@ const { runPostCompletionHooks, updateWorkItemStatus, syncPrdItemStatus, reconci
168
168
  syncPrsFromOutput, updatePrAfterReview, updatePrAfterFix, checkForLearnings, extractSkillsFromOutput,
169
169
  updateAgentHistory, updateMetrics, createReviewFeedbackForAuthor, parseAgentOutput, syncPrdFromPrs, persistVerifyPrsToPrd,
170
170
  isItemCompleted, classifyFailure: classifyFailureFallback, diagnoseEmptyOutput, processPendingRebases, resolveWorkItemPath,
171
- mergeArtifactNotes, promoteCompletionArtifacts, pruneScopeMismatchDuplicatePrs } = require('./engine/lifecycle');
171
+ mergeArtifactNotes, promoteCompletionArtifacts, pruneScopeMismatchDuplicatePrs, collapseAllDuplicatePrRecords } = require('./engine/lifecycle');
172
172
 
173
173
  // ─── Diagnostics: memory + event-loop + GC sampler (P-a1b2c3d4 / P-b2c3d4e5) ─
174
174
 
@@ -6317,6 +6317,19 @@ function materializePlansAsWorkItems(config) {
6317
6317
  log('info', `Re-opened work item ${itemId} in ${rProjName} (cross-project, PRD item set to ${rItem.status})`);
6318
6318
  }
6319
6319
 
6320
+ // Stamp workItemId onto each newly created PRD item immediately after WI creation (P-b3c2d4e5).
6321
+ // Uses mutatePrdLocked so the write is protected by a file lock — not a bare safeWrite.
6322
+ if (newlyCreatedIds.size > 0) {
6323
+ try {
6324
+ mutatePrdLocked(file, plan, (current) => {
6325
+ for (const feature of (current.missing_features || [])) {
6326
+ if (newlyCreatedIds.has(feature.id)) feature.workItemId = feature.id;
6327
+ }
6328
+ return current;
6329
+ }, { skipWriteIfUnchanged: true });
6330
+ } catch (e) { log('warn', `workItemId stamp failed for ${file}: ${e.message}`); }
6331
+ }
6332
+
6320
6333
  totalCreated += created;
6321
6334
  }
6322
6335
 
@@ -8211,6 +8224,45 @@ function normalizeAc(ac) {
8211
8224
  * @param {string} [options.workType] - Work type (used for ASK-specific vars)
8212
8225
  * @returns {{ needsReview: boolean, checkpointCount: number|null }} checkpoint side-effect info
8213
8226
  */
8227
+ /**
8228
+ * SHERLOC (P-mqyp0008v022w3x4): detect prior completed explore WI that
8229
+ * references this fix WI via its `references` array. Returns the explore
8230
+ * WI's `resultSummary` string, or '' when none found.
8231
+ *
8232
+ * Lookup: scan all work items for type=explore, status=done, non-empty
8233
+ * resultSummary, and a reference whose URL includes the fix WI's ID.
8234
+ * The explore WI is expected to have been dispatched as a pre-localization
8235
+ * step and to carry a back-reference to the fix WI it was researching.
8236
+ *
8237
+ * @param {object} item - the fix work item being dispatched
8238
+ * @param {object} config - engine config (passed to getWorkItems)
8239
+ * @returns {string}
8240
+ */
8241
+ function resolvePriorExploreContext(item, config) {
8242
+ if (!item || !item.id) return '';
8243
+ try {
8244
+ const allItems = queries.getWorkItems(config, { enrich: false });
8245
+ const candidates = allItems.filter(wi =>
8246
+ wi.type === WORK_TYPE.EXPLORE &&
8247
+ DONE_STATUSES.has(wi.status) &&
8248
+ wi.resultSummary &&
8249
+ Array.isArray(wi.references) &&
8250
+ wi.references.some(r => r && typeof r.url === 'string' && r.url.includes(item.id))
8251
+ );
8252
+ if (candidates.length === 0) return '';
8253
+ // Prefer most recently completed; fall back to most recently created.
8254
+ candidates.sort((a, b) => {
8255
+ const ta = a.completedAt || a.created || '';
8256
+ const tb = b.completedAt || b.created || '';
8257
+ return tb.localeCompare(ta);
8258
+ });
8259
+ return candidates[0].resultSummary || '';
8260
+ } catch (e) {
8261
+ log('warn', `resolvePriorExploreContext for ${item.id}: ${e.message}`);
8262
+ return '';
8263
+ }
8264
+ }
8265
+
8214
8266
  function buildWorkItemDispatchVars(item, vars, config, options = {}) {
8215
8267
  const { worktreePath, includeNotes = true, workType } = options;
8216
8268
 
@@ -8267,6 +8319,13 @@ function buildWorkItemDispatchVars(item, vars, config, options = {}) {
8267
8319
  vars.notes_content = 'See the **Team Notes** section above for the latest shared team context.';
8268
8320
  }
8269
8321
 
8322
+ // SHERLOC (P-mqyp0008v022w3x4): inject prior explore context for fix dispatches.
8323
+ // When a completed explore WI has a reference to this fix WI, surface its
8324
+ // resultSummary so the fix agent starts with pre-localized fault context.
8325
+ if (workType === WORK_TYPE.FIX) {
8326
+ vars.prior_explore_context = resolvePriorExploreContext(item, config);
8327
+ }
8328
+
8270
8329
  // Resolve implicit context references (e.g., "ripley's plan", "the latest plan")
8271
8330
  const resolvedCtx = resolveTaskContext(item, config);
8272
8331
  if (resolvedCtx.additionalContext) {
@@ -9844,6 +9903,15 @@ async function tickInner() {
9844
9903
  log('warn', `[pull-requests] scope-mismatch sweep error: ${err?.message || err}`);
9845
9904
  }
9846
9905
 
9906
+ // P-e9f0a2b4 — Collapse prNumber duplicates (different canonical IDs, same
9907
+ // prNumber) that accumulated before upsert-time dedup was added. One mutation
9908
+ // per affected scope per reconcile tick; normally a no-op after first run.
9909
+ try {
9910
+ collapseAllDuplicatePrRecords(config);
9911
+ } catch (err) {
9912
+ log('warn', `[pull-requests] collapse-duplicates sweep error: ${err?.message || err}`);
9913
+ }
9914
+
9847
9915
  // P-6b3a9f78 — Shared-branch PR reconciler (recovery sweep). Backfills the
9848
9916
  // engine PR store for shared-branch plans whose aggregate remote PR exists
9849
9917
  // but is untracked / under-linked, so the dashboard renders the single
@@ -10300,15 +10368,12 @@ async function tickInner() {
10300
10368
  } else {
10301
10369
  // P-a3f9b205: surface the per-project live-mode gate. Order matters:
10302
10370
  // max_concurrency / agent_busy / branch_locked are all more specific
10303
- // and win when both apply. Also check the pending item's own resolved
10304
- // checkout mode so worktree-mode items on hybrid projects are not
10305
- // falsely annotated live_checkout_busy (mirrors seeding guard above).
10371
+ // and win when both apply.
10306
10372
  const itemProjName = item.project || item.meta?.project?.name || null;
10307
10373
  if (
10308
10374
  itemProjName
10309
10375
  && !READ_ONLY_ROOT_TASK_TYPES.has(item.type)
10310
10376
  && postLiveProjectsInUse.has(itemProjName)
10311
- && shared.resolveCheckoutMode(shared.findProjectByName(shared.getProjects(config), itemProjName), item.type) === 'live'
10312
10377
  ) {
10313
10378
  reason = 'live_checkout_busy';
10314
10379
  }
@@ -10504,6 +10569,8 @@ module.exports = {
10504
10569
 
10505
10570
  // Shared helpers (used by lifecycle.js and tests)
10506
10571
  reconcileItemsWithPrs, detectDependencyCycles,
10572
+ safePrdProjectSlug, safePrdFilenameForProject, // exported for testing (W-mqyn7joo0004079f)
10573
+ isSoftFixDispatch, // exported for testing (W-mqyn7joo0004079f)
10507
10574
  areDependenciesMet, // exported for testing (P-bf04-decompose-zero-children)
10508
10575
  parseConflictFiles, pruneAncestorDeps, preflightMergeSimulation, // exported for testing
10509
10576
  resolveDependencyBranches, buildCrossRepoDepsSection, // exported for testing (P-faea3206)
@@ -10524,6 +10591,7 @@ module.exports = {
10524
10591
 
10525
10592
  // Playbooks
10526
10593
  renderPlaybook, validatePlaybookVars, PLAYBOOK_REQUIRED_VARS, buildWorkItemDispatchVars,
10594
+ resolvePriorExploreContext, // exported for testing (P-mqyp0008v022w3x4 SHERLOC)
10527
10595
  renderProjectWorkItemPromptForAgent, // exported for testing
10528
10596
 
10529
10597
  // Timeout / Steering / Idle (re-exported from engine/timeout.js)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2293",
3
+ "version": "0.1.2295",
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"
package/playbooks/fix.md CHANGED
@@ -19,6 +19,15 @@ When `{{pr_id}}`/`{{pr_branch}}` identify an existing pull request, this is a fi
19
19
 
20
20
  {{checkpoint_context}}
21
21
 
22
+ {{#prior_explore_context}}
23
+ ## Exploration Context
24
+
25
+ A prior explore work item investigated this issue. Use this context as your starting point for fault localization:
26
+
27
+ {{prior_explore_context}}
28
+
29
+ {{/prior_explore_context}}
30
+
22
31
  ## Review Findings to Address
23
32
 
24
33
  {{review_note}}