@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.
- package/dashboard/js/settings.js +0 -45
- package/dashboard.js +3 -32
- package/docs/deprecated.json +6 -0
- package/docs/live-checkout-mode.md +0 -54
- package/docs/named-agents.md +1 -1
- package/docs/workspace-manifests.md +3 -4
- package/engine/features.js +4 -16
- package/engine/github.js +3 -0
- package/engine/lifecycle.js +108 -1
- package/engine/playbook.js +9 -4
- package/engine/queries.js +43 -83
- package/engine/shared.js +140 -22
- package/engine.js +73 -5
- package/package.json +1 -1
- package/playbooks/fix.md +9 -0
package/dashboard/js/settings.js
CHANGED
|
@@ -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' });
|
package/docs/deprecated.json
CHANGED
|
@@ -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:
|
package/docs/named-agents.md
CHANGED
|
@@ -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
|
-
`
|
|
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.
|
|
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 | `
|
|
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,
|
|
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
|
|
package/engine/features.js
CHANGED
|
@@ -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.
|
|
103
|
-
//
|
|
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,
|
package/engine/lifecycle.js
CHANGED
|
@@ -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
|
|
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
|
};
|
package/engine/playbook.js
CHANGED
|
@@ -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
|
-
//
|
|
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,
|
|
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
|
|
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
|
|
2157
|
-
//
|
|
2158
|
-
//
|
|
2159
|
-
//
|
|
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
|
|
2165
|
-
// funnel each PRD through this one processor
|
|
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.
|
|
2223
|
-
//
|
|
2224
|
-
// the join silently falls back to the legacy
|
|
2225
|
-
|
|
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
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
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
|
|
2243
|
-
|
|
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
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
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 &&
|
|
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
|
-
//
|
|
2683
|
-
|
|
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 (
|
|
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 !== '')
|
|
3647
|
-
|
|
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.
|
|
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.
|
|
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}}
|