@yemi33/minions 0.1.2305 → 0.1.2307

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.
Files changed (39) hide show
  1. package/bin/minions.js +1 -0
  2. package/dashboard/js/refresh.js +17 -13
  3. package/dashboard/js/render-other.js +251 -3
  4. package/dashboard/js/render-plans.js +1 -1
  5. package/dashboard/js/render-prs.js +4 -1
  6. package/dashboard/js/render-utils.js +13 -0
  7. package/dashboard/js/render-work-items.js +11 -13
  8. package/dashboard/js/settings.js +30 -3
  9. package/dashboard/slim/body.html +6 -22
  10. package/dashboard/slim/js/modals-tiles.js +32 -22
  11. package/dashboard/slim/js/plans.js +24 -516
  12. package/dashboard/slim/styles.css +10 -7
  13. package/dashboard/styles.css +58 -0
  14. package/dashboard.js +66 -9
  15. package/docs/completion-reports.md +27 -2
  16. package/docs/copilot-cli-schema.md +31 -1
  17. package/docs/design-state-storage.md +1 -1
  18. package/docs/harness-transparency.md +18 -0
  19. package/docs/live-checkout-mode.md +57 -8
  20. package/engine/ado-comment.js +5 -2
  21. package/engine/ado.js +37 -0
  22. package/engine/cli.js +58 -3
  23. package/engine/comment-format.js +82 -2
  24. package/engine/consolidation.js +59 -2
  25. package/engine/discover-project-skills.js +4 -0
  26. package/engine/dispatch.js +36 -1
  27. package/engine/gh-comment.js +8 -3
  28. package/engine/lifecycle.js +241 -25
  29. package/engine/live-checkout.js +335 -7
  30. package/engine/playbook.js +2 -2
  31. package/engine/pre-dispatch-eval.js +54 -5
  32. package/engine/queries.js +11 -7
  33. package/engine/shared.js +96 -6
  34. package/engine/supervisor.js +144 -19
  35. package/engine/watchdog.js +10 -0
  36. package/engine.js +210 -5
  37. package/package.json +1 -1
  38. package/playbooks/review.md +2 -0
  39. package/playbooks/shared-rules.md +9 -0
@@ -4981,6 +4981,77 @@ function pickReReviewAgentHints(config, opts = {}) {
4981
4981
  return out;
4982
4982
  }
4983
4983
 
4984
+ /**
4985
+ * P-mqyp0009y025z6a7 — Goal invalidation.
4986
+ *
4987
+ * When a completing WI's report carries a non-empty `invalidates: [wi-id, …]`
4988
+ * array, cancel each listed WI that is currently pending or queued.
4989
+ *
4990
+ * Rules:
4991
+ * - Only cancels WIs in status 'pending' or 'queued'.
4992
+ * - WIs in any other status (dispatched, done, failed, cancelled, …) are
4993
+ * skipped with a warning log — they are not cancelled.
4994
+ * - Missing WI IDs produce a warning but do not throw.
4995
+ * - Uses mutateWorkItems (SQL-canonical) so the dashboard reflects the change.
4996
+ */
4997
+ function applyGoalInvalidation(sourceWiId, invalidates, config) {
4998
+ if (!Array.isArray(invalidates) || invalidates.length === 0) return;
4999
+
5000
+ const wiPaths = getWorkItemPaths(config);
5001
+ const cancellableStatuses = new Set([WI_STATUS.PENDING, WI_STATUS.QUEUED]);
5002
+ const cancellationReason = `invalidated-by:${sourceWiId}`;
5003
+
5004
+ for (const targetId of invalidates) {
5005
+ if (!targetId || typeof targetId !== 'string') continue;
5006
+
5007
+ // Find which file contains the WI.
5008
+ let foundPath = null;
5009
+ let foundStatus = null;
5010
+ for (const wiPath of wiPaths) {
5011
+ try {
5012
+ const items = readOptionalJsonStrict(wiPath, 'work-items', Array.isArray) || [];
5013
+ const item = items.find(i => i && i.id === targetId);
5014
+ if (item) {
5015
+ foundPath = wiPath;
5016
+ foundStatus = item.status;
5017
+ break;
5018
+ }
5019
+ } catch { /* best-effort scan */ }
5020
+ }
5021
+
5022
+ if (!foundPath) {
5023
+ log('warn', `Goal invalidation: WI ${targetId} listed in invalidates[] by ${sourceWiId} not found — skipping`);
5024
+ continue;
5025
+ }
5026
+
5027
+ if (!cancellableStatuses.has(foundStatus)) {
5028
+ log('warn', `Goal invalidation: WI ${targetId} is in status '${foundStatus}' — only pending/queued WIs are cancelled; skipping`);
5029
+ continue;
5030
+ }
5031
+
5032
+ // Cancel the WI via mutateWorkItems so the SQL store + state event are updated.
5033
+ try {
5034
+ mutateWorkItems(foundPath, (items) => {
5035
+ if (!Array.isArray(items)) return items;
5036
+ const wi = items.find(i => i && i.id === targetId);
5037
+ if (!wi) return items;
5038
+ // Re-check status inside the lock to avoid TOCTOU races.
5039
+ if (!cancellableStatuses.has(wi.status)) {
5040
+ log('warn', `Goal invalidation: WI ${targetId} changed to '${wi.status}' before cancel lock acquired — skipping`);
5041
+ return items;
5042
+ }
5043
+ wi.status = WI_STATUS.CANCELLED;
5044
+ wi.cancellationReason = cancellationReason;
5045
+ wi.cancelledAt = ts();
5046
+ log('info', `Goal invalidation: cancelled WI ${targetId} (${cancellationReason})`);
5047
+ return items;
5048
+ });
5049
+ } catch (err) {
5050
+ log('warn', `Goal invalidation: failed to cancel WI ${targetId}: ${err.message}`);
5051
+ }
5052
+ }
5053
+ }
5054
+
4984
5055
  async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config, opts) {
4985
5056
 
4986
5057
  const detectPhantom = !!(opts && opts.detectPhantom);
@@ -5459,6 +5530,11 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5459
5530
  promoteCompletionArtifacts(meta, agentId, dispatchItem.id, structuredCompletion, { resultSummary });
5460
5531
  // M003 — auto-dispatch a live-validation WI when a coding WI completes.
5461
5532
  try { autoDispatchLiveValidationWi(meta, config); } catch (err) { log('warn', `autoDispatchLiveValidationWi: ${err.message}`); }
5533
+ // P-mqyp0009y025z6a7 — goal invalidation: cancel any pending/queued WIs
5534
+ // listed in the completion report's optional `invalidates[]` field.
5535
+ if (structuredCompletion && Array.isArray(structuredCompletion.invalidates) && structuredCompletion.invalidates.length > 0) {
5536
+ try { applyGoalInvalidation(meta.item.id, structuredCompletion.invalidates, config); } catch (err) { log('warn', `applyGoalInvalidation: ${err.message}`); }
5537
+ }
5462
5538
  }
5463
5539
  // Failure retry is handled by completeDispatch in dispatch.js — not duplicated here.
5464
5540
  // Only clear _decomposing flag on failure so decompose items don't get permanently stuck.
@@ -5927,12 +6003,83 @@ function syncPrdFromPrs(config) {
5927
6003
  if (sourcePlan) stampPrdItemPrUrl(itemId, sourcePlan, pr.url);
5928
6004
  }
5929
6005
  }
6006
+ // Advance PRD top-level status when all items are done and all linked PRs merged
6007
+ advancePrdStatusOnAllItemsDone(config);
5930
6008
  } catch (err) {
5931
6009
  // Non-fatal — log and continue
5932
6010
  try { log('warn', `syncPrdFromPrs error: ${err?.message || err}`); } catch { /* engine not available */ }
5933
6011
  }
5934
6012
  }
5935
6013
 
6014
+ // ─── PRD Top-Level Status Auto-Completion (P-d7e8f9a1) ───────────────────────
6015
+ // Runs at the end of syncPrdFromPrs (same PR-poll cadence). Advances PRD
6016
+ // status to 'completed' when every missing_features item satisfies both:
6017
+ // 1. A work item with that id exists in done status (DONE_STATUSES)
6018
+ // 2. The linked PR (WI._pr) is merged — or the item has no linked PR
6019
+ // (non-code items that never raised a PR)
6020
+ // Paused, rejected, awaiting-approval, completed, and archived PRDs are
6021
+ // skipped via _PRD_FROZEN_STATUSES. The locked write is idempotent.
6022
+ function advancePrdStatusOnAllItemsDone(config) {
6023
+ if (!fs.existsSync(PRD_DIR)) return;
6024
+ let prdFiles;
6025
+ try { prdFiles = fs.readdirSync(PRD_DIR).filter(f => f.endsWith('.json')); } catch { return; }
6026
+ if (!prdFiles.length) return;
6027
+
6028
+ const allWorkItems = queries.getWorkItems(config);
6029
+ if (!allWorkItems.length) return;
6030
+
6031
+ // Index done WIs by id for O(1) lookup
6032
+ const doneWiById = new Map();
6033
+ for (const wi of allWorkItems) {
6034
+ if (wi.id && DONE_STATUSES.has(wi.status)) doneWiById.set(wi.id, wi);
6035
+ }
6036
+ if (!doneWiById.size) return;
6037
+
6038
+ // Collect all PRs by id across all projects for merged-status lookup
6039
+ const prById = new Map();
6040
+ for (const project of shared.getProjects(config)) {
6041
+ for (const pr of safeJsonArr(shared.projectPrPath(project))) {
6042
+ if (pr?.id) prById.set(pr.id, pr);
6043
+ }
6044
+ }
6045
+
6046
+ for (const file of prdFiles) {
6047
+ try {
6048
+ const fpath = path.join(PRD_DIR, file);
6049
+ const plan = safeJsonNoRestore(fpath);
6050
+ if (!plan?.missing_features?.length) continue;
6051
+ // Skip frozen PRDs — paused/rejected/awaiting-approval/completed/archived
6052
+ if (_PRD_FROZEN_STATUSES.has(plan.status)) continue;
6053
+
6054
+ const allDone = plan.missing_features.every(feature => {
6055
+ const wi = doneWiById.get(feature.id);
6056
+ if (!wi) return false; // no done WI for this feature
6057
+ // If the WI has a linked PR, it must be merged
6058
+ if (wi._pr) {
6059
+ const pr = prById.get(wi._pr);
6060
+ if (!pr) return true; // PR no longer in tracker (swept after merge) — allow
6061
+ return pr.status === PR_STATUS.MERGED;
6062
+ }
6063
+ // No linked PR — non-code item or PR not tracked; done WI is sufficient
6064
+ return true;
6065
+ });
6066
+
6067
+ if (!allDone) continue;
6068
+
6069
+ mutateJsonFileLocked(fpath, (data) => {
6070
+ // Re-check under lock — another writer may have frozen this PRD
6071
+ if (_PRD_FROZEN_STATUSES.has(data.status)) return data;
6072
+ data.status = PLAN_STATUS.COMPLETED;
6073
+ if (!data.completedAt) data.completedAt = ts();
6074
+ return data;
6075
+ }, { skipWriteIfUnchanged: true });
6076
+ log('info', `PRD auto-advance: ${file} → completed (all items done, all linked PRs merged)`);
6077
+ } catch (err) {
6078
+ log('warn', `advancePrdStatusOnAllItemsDone error for ${file}: ${err?.message || err}`);
6079
+ }
6080
+ }
6081
+ }
6082
+
5936
6083
  // ─── Persist verify/E2E aggregate PRs onto the PRD JSON (W-mqps9jlb) ──────────
5937
6084
  // The PRD view's "E2E Aggregate PRs" section (dashboard/js/render-prd.js#
5938
6085
  // renderE2eSection) sources its PRs from the LIVE tracker (pull-requests.json).
@@ -6161,18 +6308,34 @@ function diagnoseEmptyOutput(failureClass, code, elapsedMs) {
6161
6308
  return `[empty-output: process exited in ${elapsedMs}ms \u2014 possible causes: machine sleep, network unavailability, auth failure]`;
6162
6309
  }
6163
6310
 
6164
- // W-mqba5ulq000nd255 — Reconciliation sweep: delete PR records flagged with
6165
- // `_invalidProjectScope: { reason: "pr_scope_mismatch" }` IFF a sibling
6166
- // record for the same canonical pr.id exists in another project whose scope
6167
- // matches the PR URL (i.e. the correctly-scoped project owns the canonical
6168
- // record). Sibling-less mismatches are preserved as tracking. Runs once per
6169
- // tick from engine.js after the ADO/GitHub reconcile polls finish.
6311
+ // W-mqba5ulq000nd255 / W-mr2cqq12000b04f8 — Reconciliation sweep with two
6312
+ // defenses against mis-scoped `_invalidProjectScope: { reason: "pr_scope_mismatch" }`
6313
+ // records:
6314
+ //
6315
+ // 1. PRUNE — a sibling record for the same canonical pr.id already exists in
6316
+ // the correctly-scoped project (e.g. a fresh dispatch there re-synced it).
6317
+ // The mis-scoped duplicate is deleted; the sibling is the surviving record.
6170
6318
  //
6171
- // Returns { pruned, scanned } so the engine tick can log a summary line.
6319
+ // 2. RELOCATE — no sibling exists yet, but a project IS configured whose
6320
+ // scope matches the PR's real repo (e.g. work dispatched under project
6321
+ // "minions-opg" opened a PR against `yemi33/minions`, and a "minions"
6322
+ // project mapping to that repo is configured). Without this, the record
6323
+ // would sit mis-scoped forever — the correct project's tracker (read by
6324
+ // repo-scoped schedules such as daily-merge-approved-minions-prs) would
6325
+ // never see it. The record is moved: upserted into the correct project's
6326
+ // file (which clears `_invalidProjectScope` since it's now correctly
6327
+ // scoped) and removed from the mis-scoped project's file.
6328
+ //
6329
+ // When no configured project matches the PR's real scope at all, the record
6330
+ // is left in place with its `_invalidProjectScope` stamp so operators can
6331
+ // still find it (still surfaced via GET /api/pull-requests aggregation).
6332
+ // Runs once per tick from engine.js after the ADO/GitHub reconcile polls finish.
6333
+ //
6334
+ // Returns { pruned, relocated, scanned } so the engine tick can log a summary line.
6172
6335
  function pruneScopeMismatchDuplicatePrs(config) {
6173
6336
  config = config || getConfig();
6174
6337
  const projects = shared.getProjects(config) || [];
6175
- if (projects.length === 0) return { pruned: 0, scanned: 0 };
6338
+ if (projects.length === 0) return { pruned: 0, relocated: 0, scanned: 0 };
6176
6339
 
6177
6340
  // Map project name -> canonical scope so we can find which project IS the
6178
6341
  // correctly-scoped owner for a given mismatch record.
@@ -6197,13 +6360,17 @@ function pruneScopeMismatchDuplicatePrs(config) {
6197
6360
  }
6198
6361
 
6199
6362
  // Build per-project delete sets keyed by id, so we batch one mutation per
6200
- // affected project file.
6201
- const deletesByProject = new Map(); // projectName -> Set(prId)
6363
+ // affected project file. Track prune-deletes and relocate-deletes in
6364
+ // separate id sets (per project) so `pruned` vs `relocated` counts stay
6365
+ // accurate even though both end up removing rows from the same file.
6366
+ const pruneDeletesByProject = new Map(); // projectName -> Set(prId)
6367
+ const relocateDeletesByProject = new Map(); // projectName -> Set(prId)
6368
+ // Records to insert into their correctly-scoped project's file.
6369
+ const relocationsByTargetProject = new Map(); // projectName -> [{ rec, owningScope }]
6202
6370
  let scanned = 0;
6203
6371
  let pruned = 0;
6204
6372
 
6205
6373
  for (const records of byId.values()) {
6206
- if (records.length < 2) continue;
6207
6374
  for (const rec of records) {
6208
6375
  scanned++;
6209
6376
  if (!rec._invalidProjectScope || rec._invalidProjectScope.reason !== 'pr_scope_mismatch') continue;
@@ -6211,32 +6378,79 @@ function pruneScopeMismatchDuplicatePrs(config) {
6211
6378
  if (!correctScope) continue;
6212
6379
  const correctProject = projectByScope.get(correctScope);
6213
6380
  if (!correctProject) continue; // no configured project owns the URL — keep as tracking
6214
- // Sibling check: is there a record under the correct scope for the same id?
6215
- const sibling = records.find(r => r !== rec && r._scope === correctProject.name);
6216
- if (!sibling) continue;
6217
- // Safe to prune.
6218
6381
  const owningScope = rec._scope;
6219
6382
  if (!owningScope || owningScope === 'central') continue;
6220
- if (!deletesByProject.has(owningScope)) deletesByProject.set(owningScope, new Set());
6221
- deletesByProject.get(owningScope).add(rec.id);
6383
+ if (owningScope === correctProject.name) continue; // already correctly scoped
6384
+ // Sibling check: is there a record under the correct scope for the same id?
6385
+ const sibling = records.find(r => r !== rec && r._scope === correctProject.name);
6386
+ if (sibling) {
6387
+ // Safe to prune — the sibling already owns the canonical record.
6388
+ if (!pruneDeletesByProject.has(owningScope)) pruneDeletesByProject.set(owningScope, new Set());
6389
+ pruneDeletesByProject.get(owningScope).add(rec.id);
6390
+ } else {
6391
+ // No sibling anywhere: relocate the record into the correctly-scoped
6392
+ // project instead of leaving it stuck mis-scoped indefinitely.
6393
+ if (!relocationsByTargetProject.has(correctProject.name)) relocationsByTargetProject.set(correctProject.name, []);
6394
+ relocationsByTargetProject.get(correctProject.name).push({ rec, owningScope });
6395
+ }
6222
6396
  }
6223
6397
  }
6224
6398
 
6225
- if (deletesByProject.size === 0) return { pruned: 0, scanned };
6399
+ // Relocate first (insert into target), only marking the source record for
6400
+ // deletion once the insert into the correct project succeeds — this avoids
6401
+ // losing a record if the insert throws mid-way.
6402
+ let relocated = 0;
6403
+ for (const [targetProjectName, entries] of relocationsByTargetProject) {
6404
+ const targetProject = projects.find(p => p.name === targetProjectName);
6405
+ if (!targetProject) continue;
6406
+ const targetPrPath = projectPrPath(targetProject);
6407
+ for (const { rec, owningScope } of entries) {
6408
+ const entry = { ...rec };
6409
+ delete entry._scope;
6410
+ delete entry._invalidProjectScope;
6411
+ delete entry.project; // let upsertPullRequestRecord backfill from targetProject
6412
+ try {
6413
+ const result = shared.upsertPullRequestRecord(targetPrPath, entry, {
6414
+ project: targetProject,
6415
+ itemIds: Array.isArray(rec.prdItems) ? rec.prdItems : null,
6416
+ });
6417
+ if (result.created || result.linked) {
6418
+ relocated++;
6419
+ if (!relocateDeletesByProject.has(owningScope)) relocateDeletesByProject.set(owningScope, new Set());
6420
+ relocateDeletesByProject.get(owningScope).add(rec.id);
6421
+ log('info', `[pull-requests] relocated mis-scoped PR ${rec.id} from project=${owningScope} to correctly-scoped project=${targetProjectName}`);
6422
+ }
6423
+ } catch (err) {
6424
+ log('warn', `pruneScopeMismatchDuplicatePrs: failed to relocate ${rec.id} to ${targetProjectName}: ${err?.message || err}`);
6425
+ }
6426
+ }
6427
+ }
6428
+
6429
+ // Merge prune + relocate delete sets per project so each file is mutated once.
6430
+ const deletesByProject = new Map();
6431
+ for (const [projectName, ids] of pruneDeletesByProject) {
6432
+ deletesByProject.set(projectName, new Set(ids));
6433
+ }
6434
+ for (const [projectName, ids] of relocateDeletesByProject) {
6435
+ if (!deletesByProject.has(projectName)) deletesByProject.set(projectName, new Set());
6436
+ for (const id of ids) deletesByProject.get(projectName).add(id);
6437
+ }
6438
+
6439
+ if (deletesByProject.size === 0) return { pruned: 0, relocated, scanned };
6226
6440
 
6227
6441
  for (const [projectName, idsToDelete] of deletesByProject) {
6228
6442
  const project = projects.find(p => p.name === projectName);
6229
6443
  if (!project) continue;
6230
6444
  const prPath = projectPrPath(project);
6445
+ const pruneIds = pruneDeletesByProject.get(projectName) || new Set();
6231
6446
  try {
6232
6447
  shared.mutatePullRequests(prPath, (prs) => {
6233
- const before = prs.length;
6448
+ const deletedIds = prs.filter(p => idsToDelete.has(p?.id)).map(p => p.id);
6234
6449
  const next = prs.filter(p => !idsToDelete.has(p?.id));
6235
- const deleted = before - next.length;
6236
- if (deleted > 0) {
6237
- pruned += deleted;
6238
- for (const id of idsToDelete) {
6239
- log('info', `[pull-requests] pruned scope-mismatch duplicate ${id} from project=${projectName} (sibling exists in correctly-scoped project)`);
6450
+ for (const id of deletedIds) {
6451
+ if (pruneIds.has(id)) {
6452
+ pruned++;
6453
+ log('info', `[pull-requests] removed mis-scoped duplicate ${id} from project=${projectName}`);
6240
6454
  }
6241
6455
  }
6242
6456
  return next;
@@ -6246,7 +6460,7 @@ function pruneScopeMismatchDuplicatePrs(config) {
6246
6460
  }
6247
6461
  }
6248
6462
 
6249
- return { pruned, scanned };
6463
+ return { pruned, relocated, scanned };
6250
6464
  }
6251
6465
 
6252
6466
  // Repair helper: collapse prNumber duplicates across all project-scoped and
@@ -6400,7 +6614,9 @@ module.exports = {
6400
6614
  mergeArtifactNotes,
6401
6615
  promoteCompletionArtifacts,
6402
6616
  runPostCompletionHooks,
6617
+ applyGoalInvalidation,
6403
6618
  syncPrdFromPrs,
6619
+ advancePrdStatusOnAllItemsDone,
6404
6620
  persistVerifyPrsToPrd,
6405
6621
  resolveWorkItemPath,
6406
6622
  isItemCompleted,