@yemi33/minions 0.1.2170 → 0.1.2172

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 CHANGED
@@ -5145,13 +5145,11 @@ const server = http.createServer(async (req, res) => {
5145
5145
  const wiPath = shared.projectWorkItemsPath(project);
5146
5146
  let existingVerify = null;
5147
5147
  mutateWorkItems(wiPath, items => {
5148
- const v = items.find(w => w.sourcePlan === body.file && w.itemType === 'verify');
5149
- if (v && (v.status === 'done' || v.status === 'failed')) {
5150
- v.status = 'pending';
5151
- delete v.completedAt;
5152
- delete v.dispatched_to;
5153
- delete v.dispatched_at;
5154
- v._retryCount = 0;
5148
+ const v = items.find(w => w.sourcePlan === body.file && w.itemType === WORK_TYPE.VERIFY);
5149
+ if (v && (v.status === WI_STATUS.DONE || v.status === WI_STATUS.FAILED)) {
5150
+ // BUG-M10: use shared.reopenWorkItem so re-verify resets mirror
5151
+ // every other re-dispatch path (also sets _reopened=true).
5152
+ reopenWorkItem(v);
5155
5153
  existingVerify = v;
5156
5154
  } else if (v) {
5157
5155
  existingVerify = v;
@@ -5176,7 +5174,7 @@ const server = http.createServer(async (req, res) => {
5176
5174
  if (project) {
5177
5175
  const wiPath = shared.projectWorkItemsPath(project);
5178
5176
  const items = safeJsonArr(wiPath);
5179
- const verify = items.find(w => w.sourcePlan === body.file && w.itemType === 'verify');
5177
+ const verify = items.find(w => w.sourcePlan === body.file && w.itemType === WORK_TYPE.VERIFY);
5180
5178
  if (verify) {
5181
5179
  invalidateStatusCache();
5182
5180
  invalidatePlansCache();
@@ -5901,7 +5899,7 @@ const server = http.createServer(async (req, res) => {
5901
5899
  if (body.description !== undefined) wi.description = body.description;
5902
5900
  if (body.priority !== undefined) wi.priority = body.priority;
5903
5901
  if (body.estimated_complexity !== undefined) {
5904
- wi.type = body.estimated_complexity === 'large' ? 'implement:large' : 'implement';
5902
+ wi.type = body.estimated_complexity === 'large' ? WORK_TYPE.IMPLEMENT_LARGE : WORK_TYPE.IMPLEMENT;
5905
5903
  }
5906
5904
  workItemSynced = true;
5907
5905
  }
package/engine/ado.js CHANGED
@@ -1044,7 +1044,12 @@ async function forEachActivePr(config, token, callback) {
1044
1044
  }
1045
1045
  // Remove duplicates — prefer merged/abandoned over active
1046
1046
  const bestById = new Map();
1047
- const statusRank = { merged: 3, abandoned: 2, closed: 2, active: 1 };
1047
+ const statusRank = {
1048
+ [PR_STATUS.MERGED]: 3,
1049
+ [PR_STATUS.ABANDONED]: 2,
1050
+ [PR_STATUS.CLOSED]: 2,
1051
+ [PR_STATUS.ACTIVE]: 1,
1052
+ };
1048
1053
  for (const p of currentPrs) {
1049
1054
  const existing = bestById.get(p.id);
1050
1055
  if (!existing || (statusRank[p.status] || 0) > (statusRank[existing.status] || 0)) {
@@ -2046,7 +2051,7 @@ async function reconcilePrs(config) {
2046
2051
  agent: (linkedItem?.dispatched_to || adoPr.createdBy?.displayName || 'unknown').toLowerCase(),
2047
2052
  branch,
2048
2053
  reviewStatus: REVIEW_STATUS.PENDING,
2049
- status: 'active',
2054
+ status: PR_STATUS.ACTIVE,
2050
2055
  created: adoPr.creationDate || ts(),
2051
2056
  url: prUrl,
2052
2057
  prdItems: [],
@@ -2072,7 +2077,7 @@ async function reconcilePrs(config) {
2072
2077
  agent: (linkedItem?.dispatched_to || adoPr.createdBy?.displayName || 'unknown').toLowerCase(),
2073
2078
  branch,
2074
2079
  reviewStatus: REVIEW_STATUS.PENDING,
2075
- status: 'active',
2080
+ status: PR_STATUS.ACTIVE,
2076
2081
  created: adoPr.creationDate || ts(),
2077
2082
  url: prUrl,
2078
2083
  prdItems: [confirmedItemId],
package/engine/github.js CHANGED
@@ -252,6 +252,33 @@ async function _resolveViewerLogin(slug) {
252
252
  }
253
253
  }
254
254
 
255
+ // OQ-Y (P-oqy-viewer-fail-closed) — Fail-closed counter for null
256
+ // `_resolveViewerLogin` results. When the viewer login can't be resolved,
257
+ // `_isMinionsAuthoredComment` cannot tell minions posts apart from human
258
+ // posts (the `viewerDidAuthor` gate stays false for everything), so the
259
+ // comment classifier reclassifies every minions comment as human and
260
+ // queues a spurious fix dispatch on every poll cycle. The downstream
261
+ // `check-self-authored-review-comment` skill no-ops the dispatch, so this
262
+ // is a slot/token tax rather than state corruption — but the frequency
263
+ // data is worth tracking so future audits can prioritize a deeper fix
264
+ // (e.g. a credentialed re-probe of the gh CLI). Global counter under
265
+ // `_engine.viewerLoginResolutionFailures`; surfaced through the existing
266
+ // engine metrics aggregation (`getMetrics()` in `engine/queries.js`).
267
+ function _incrementViewerLoginResolutionFailures() {
268
+ try {
269
+ const metricsPath = path.join(MINIONS_DIR, 'engine', 'metrics.json');
270
+ mutateJsonFileLocked(metricsPath, (metrics) => {
271
+ metrics = metrics || {};
272
+ if (!metrics._engine) metrics._engine = {};
273
+ metrics._engine.viewerLoginResolutionFailures =
274
+ (metrics._engine.viewerLoginResolutionFailures || 0) + 1;
275
+ return metrics;
276
+ });
277
+ } catch (err) {
278
+ log('warn', `telemetry: viewerLoginResolutionFailures metric write failed: ${err?.message || err}`);
279
+ }
280
+ }
281
+
255
282
  // Test hook: lets tests prime or clear a cached viewer login per account
256
283
  // without shelling out. Pass `(account, null)` to force a re-resolve.
257
284
  // W-mp76pw7a001da7c1: signature changed from `(login)` → `(account, login)`
@@ -536,7 +563,12 @@ async function forEachActiveGhPr(config, callback) {
536
563
  }
537
564
  // Remove duplicates — prefer merged/abandoned over active
538
565
  const bestById = new Map();
539
- const statusRank = { merged: 3, abandoned: 2, closed: 2, active: 1 };
566
+ const statusRank = {
567
+ [PR_STATUS.MERGED]: 3,
568
+ [PR_STATUS.ABANDONED]: 2,
569
+ [PR_STATUS.CLOSED]: 2,
570
+ [PR_STATUS.ACTIVE]: 1,
571
+ };
540
572
  for (const p of currentPrs) {
541
573
  const existing = bestById.get(p.id);
542
574
  if (!existing || (statusRank[p.status] || 0) > (statusRank[existing.status] || 0)) {
@@ -1111,6 +1143,21 @@ async function pollPrHumanComments(config) {
1111
1143
  // account pay only the first shell-out.
1112
1144
  const totalUpdated = await forEachActiveGhPr(config, async (project, pr, prNum, slug) => {
1113
1145
  const viewerLogin = await _resolveViewerLogin(slug);
1146
+ // OQ-Y (P-oqy-viewer-fail-closed) — Fail-closed when the viewer login
1147
+ // can't be resolved. `_isMinionsAuthoredComment` gates on
1148
+ // `c.viewerDidAuthor === true`, and `_backfillViewerDidAuthor` is a
1149
+ // no-op when viewerLogin is null. Running the classifier without a
1150
+ // login mass-misclassifies every minions comment as human and queues
1151
+ // a redundant fix dispatch on every poll cycle (the downstream
1152
+ // `check-self-authored-review-comment` skill no-ops them, so this is
1153
+ // a slot/token tax — see audit OQ-Y, HIGH not CRITICAL). Skip the
1154
+ // entire round instead. One log + one counter increment per affected
1155
+ // PR per cycle (no per-comment fanout).
1156
+ if (!viewerLogin) {
1157
+ log('warn', `GitHub PR comment poll: skipping comment classification for ${pr.id} (slug=${slug || '<no-slug>'}) — viewer login unresolved`);
1158
+ _incrementViewerLoginResolutionFailures();
1159
+ return false;
1160
+ }
1114
1161
  // Get issue comments (general PR comments)
1115
1162
  const comments = await ghApi(`/issues/${prNum}/comments`, slug);
1116
1163
  if (!comments || !Array.isArray(comments)) return false;
@@ -1392,7 +1439,7 @@ async function reconcilePrs(config) {
1392
1439
  agent: (linkedItem?.dispatched_to || ghPr.user?.login || 'unknown').toLowerCase(),
1393
1440
  branch,
1394
1441
  reviewStatus: REVIEW_STATUS.PENDING,
1395
- status: 'active',
1442
+ status: PR_STATUS.ACTIVE,
1396
1443
  created: ghPr.created_at || ts(),
1397
1444
  url: prUrl,
1398
1445
  prdItems: [],
@@ -1418,7 +1465,7 @@ async function reconcilePrs(config) {
1418
1465
  agent: (linkedItem?.dispatched_to || ghPr.user?.login || 'unknown').toLowerCase(),
1419
1466
  branch,
1420
1467
  reviewStatus: REVIEW_STATUS.PENDING,
1421
- status: 'active',
1468
+ status: PR_STATUS.ACTIVE,
1422
1469
  created: ghPr.created_at || ts(),
1423
1470
  url: prUrl,
1424
1471
  prdItems: [confirmedItemId],
@@ -1854,6 +1901,7 @@ module.exports = {
1854
1901
  _resolveViewerLogin, // exported for testing (W-mp3bp0ha000997ab-b backfill)
1855
1902
  _setCachedViewerLogin, // exported for testing (W-mp3bp0ha000997ab-b backfill)
1856
1903
  _backfillViewerDidAuthor, // exported for testing (W-mp3bp0ha000997ab-b backfill)
1904
+ _incrementViewerLoginResolutionFailures, // exported for testing (P-oqy-viewer-fail-closed)
1857
1905
  _setExecAsyncForTest, // W-mp5trwh60008386d: test seam to mock `gh api` shell-outs
1858
1906
  _setShellSafeGhForTest, // P-f2-gh-shell (F2): test seam to mock argv-form gh api calls
1859
1907
  _setGhApiForTest, // P-f1ghstale: test seam to mock ghApi call sites for stale-signal branch coverage
@@ -199,7 +199,7 @@ function checkPlanCompletion(meta, config) {
199
199
  if (workItems.some(w => w.sourcePlan === planFile && w.itemType === 'pr')) return workItems;
200
200
  workItems.push({
201
201
  id, title: `Create PR for plan: ${plan.plan_summary || planFile}`,
202
- type: 'implement', priority: 'high',
202
+ type: WORK_TYPE.IMPLEMENT, priority: 'high',
203
203
  description: `All plan items from \`${planFile}\` are complete on branch \`${featureBranch}\`.\n\n**Branch:** \`${featureBranch}\`\n**Target:** \`${mainBranch}\`\n\n## Completed Items\n${itemSummary}`,
204
204
  status: WI_STATUS.PENDING, created: ts(), createdBy: 'engine:plan-completion',
205
205
  sourcePlan: planFile, itemType: 'pr',
@@ -246,7 +246,7 @@ function checkPlanCompletion(meta, config) {
246
246
  // Per-project existing verify lookup. Legacy single-WI plans may have
247
247
  // existingVerify.project unset; match those against the primary project name.
248
248
  const existingVerify = allWorkItems.find(w =>
249
- w.sourcePlan === planFile && w.itemType === 'verify' &&
249
+ w.sourcePlan === planFile && w.itemType === WORK_TYPE.VERIFY &&
250
250
  (w.project === projName || (!w.project && projName === primaryProject.name)));
251
251
 
252
252
  if (isActiveVerify(existingVerify)) {
@@ -389,7 +389,7 @@ function checkPlanCompletion(meta, config) {
389
389
  let created = false;
390
390
  mutateWorkItems(vWiPath, workItems => {
391
391
  // Re-check under lock to prevent races
392
- if (workItems.some(w => w.sourcePlan === planFile && w.itemType === 'verify' && (w.project === projName || (!w.project && projName === primaryProject.name)))) {
392
+ if (workItems.some(w => w.sourcePlan === planFile && w.itemType === WORK_TYPE.VERIFY && (w.project === projName || (!w.project && projName === primaryProject.name)))) {
393
393
  return workItems;
394
394
  }
395
395
  workItems.push({
@@ -397,7 +397,7 @@ function checkPlanCompletion(meta, config) {
397
397
  title: touchedProjects.length > 1
398
398
  ? `Verify plan (${projName}): ${(plan.plan_summary || planFile).slice(0, 70)}`
399
399
  : `Verify plan: ${(plan.plan_summary || planFile).slice(0, 80)}`,
400
- type: 'verify',
400
+ type: WORK_TYPE.VERIFY,
401
401
  priority: 'high',
402
402
  description,
403
403
  status: WI_STATUS.PENDING,
@@ -3137,7 +3137,7 @@ function extractSkillsFromOutput(output, agentId, dispatchItem, config, runtimeN
3137
3137
  data = data || [];
3138
3138
  if (data.some(i => i.title === `Add skill: ${name}` && i.status !== WI_STATUS.FAILED)) return data;
3139
3139
  skillId = `SK${String(data.filter(i => i.id?.startsWith('SK')).length + 1).padStart(3, '0')}`;
3140
- data.push({ id: skillId, type: 'implement', title: `Add skill: ${name}`,
3140
+ data.push({ id: skillId, type: WORK_TYPE.IMPLEMENT, title: `Add skill: ${name}`,
3141
3141
  description: `Create project-level skill \`${skillDirName}/SKILL.md\` in ${project}.\n\nWrite this file to \`${projectSkillPath}\` via a PR.\n\n## Skill Content\n\n\`\`\`\n${enrichedBlock}\n\`\`\``,
3142
3142
  priority: 'low', status: WI_STATUS.QUEUED, created: ts(), createdBy: `engine:skill-extraction:${agentName}` });
3143
3143
  return data;
@@ -4367,7 +4367,7 @@ function handleDecompositionResult(stdout, meta, config, runtimeName) {
4367
4367
  const childItem = {
4368
4368
  id: sub.id,
4369
4369
  title: sub.name || sub.title || `Sub-task of ${parentId}`,
4370
- type: (sub.estimated_complexity === 'large') ? 'implement:large' : 'implement',
4370
+ type: (sub.estimated_complexity === 'large') ? WORK_TYPE.IMPLEMENT_LARGE : WORK_TYPE.IMPLEMENT,
4371
4371
  priority: sub.priority || p.priority || 'medium',
4372
4372
  description: sub.description || '',
4373
4373
  status: WI_STATUS.PENDING,
@@ -755,7 +755,7 @@ function renderPlaybook(type, vars) {
755
755
  // type === 'review' keeps PR-82 header copy + meta.review.* outcome
756
756
  // guidance; everything else uses the generic header that points at
757
757
  // meta.skill.* in the completion report.
758
- projectSkillsBlock = (type === 'review')
758
+ projectSkillsBlock = (type === WORK_TYPE.REVIEW)
759
759
  ? discover.renderReviewSkillsBlock(filtered)
760
760
  : discover.renderProjectSkillsBlock(filtered);
761
761
  }
@@ -1195,7 +1195,7 @@ function buildPrDispatch(agentId, config, project, pr, type, extraVars, taskLabe
1195
1195
  ...extraVars,
1196
1196
  task_id: dispatchId,
1197
1197
  };
1198
- const playbookName = type === 'test' ? 'build-and-test' : (type === 'review' ? 'review' : 'fix');
1198
+ const playbookName = type === WORK_TYPE.TEST ? 'build-and-test' : (type === WORK_TYPE.REVIEW ? 'review' : 'fix');
1199
1199
  const prompt = renderPlaybook(playbookName, vars);
1200
1200
  if (!prompt) return null;
1201
1201
  return {
package/engine/queries.js CHANGED
@@ -13,7 +13,7 @@ const steering = require('./steering');
13
13
 
14
14
  const { safeRead, safeReadDir, safeJson, safeWrite, getProjects, mutateJsonFileLocked,
15
15
  projectWorkItemsPath, projectPrPath, parseSkillFrontmatter, KB_CATEGORIES,
16
- WI_STATUS, DONE_STATUSES, PRD_ITEM_STATUS, PR_STATUS, ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS } = shared;
16
+ WI_STATUS, DONE_STATUSES, WORK_TYPE, PRD_ITEM_STATUS, PR_STATUS, ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS } = shared;
17
17
 
18
18
  // ─── Defensive PR enrichment cache (W-mq5uzmc6001d708f) ──────────────────────
19
19
  //
@@ -438,7 +438,12 @@ function getMetrics() {
438
438
  if (!agent || agent.startsWith('temp-')) continue;
439
439
  prCountByAgent[agent] = (prCountByAgent[agent] || 0) + 1;
440
440
  if (pr.reviewStatus === shared.REVIEW_STATUS.APPROVED || pr.status === shared.PR_STATUS.MERGED) prApprovedByAgent[agent] = (prApprovedByAgent[agent] || 0) + 1;
441
- if (pr.reviewStatus === 'rejected') prRejectedByAgent[agent] = (prRejectedByAgent[agent] || 0) + 1;
441
+ // BUG-H13 / P-h13-rejected: this used to compare to the literal 'rejected',
442
+ // which never appears in pull-requests.json — the canonical enum value is
443
+ // REVIEW_STATUS.CHANGES_REQUESTED ('changes-requested'). The miscompare
444
+ // permanently zeroed prRejectedByAgent, and the snapshot then clobbered
445
+ // shared.trackReviewMetric()'s cumulative counter at the apply step below.
446
+ if (pr.reviewStatus === shared.REVIEW_STATUS.CHANGES_REQUESTED) prRejectedByAgent[agent] = (prRejectedByAgent[agent] || 0) + 1;
442
447
  }
443
448
 
444
449
  // Enrich agent runtime from completed dispatch entries
@@ -466,7 +471,14 @@ function getMetrics() {
466
471
  if (prCountByAgent[lower] !== undefined) {
467
472
  m.prsCreated = prCountByAgent[lower];
468
473
  m.prsApproved = prApprovedByAgent[lower] || 0;
469
- m.prsRejected = prRejectedByAgent[lower] || 0;
474
+ // BUG-H13: shared.trackReviewMetric() increments prsRejected on every
475
+ // approved→changes-requested transition (cumulative, never decremented),
476
+ // while the snapshot bucket above is a point-in-time count of PRs still
477
+ // sitting at changes-requested. The two measure different things — a PR
478
+ // that was rejected once and then re-approved is gone from the snapshot
479
+ // but still counted in the transition tracker. Take the max so the
480
+ // snapshot enrichment cannot shadow a strictly larger history value.
481
+ m.prsRejected = Math.max(m.prsRejected || 0, prRejectedByAgent[lower] || 0);
470
482
  }
471
483
  if (runtimeByAgent[agentId]) {
472
484
  // Use dispatch history as source of truth — it has full history
@@ -1338,7 +1350,7 @@ function getWorkItems(config) {
1338
1350
  const dispatch = getDispatch();
1339
1351
  const activeByWiId = new Map((dispatch.active || []).map(d => [d.meta?.item?.id, d.agent]));
1340
1352
  for (const item of allItems) {
1341
- if (item.status === 'dispatched' && !item.dispatched_to && !item.agent) {
1353
+ if (item.status === WI_STATUS.DISPATCHED && !item.dispatched_to && !item.agent) {
1342
1354
  const activeAgent = activeByWiId.get(item.id);
1343
1355
  if (activeAgent) item.dispatched_to = activeAgent;
1344
1356
  }
@@ -1666,7 +1678,7 @@ function getPrdInfo(config) {
1666
1678
  // resolves to, not raw itemIds.length: a PRD item + sibling review-followup
1667
1679
  // sub-WIs all resolve to one PRD item and must still render. (W-mpem52qn)
1668
1680
  const distinctPrdCount = countDistinctPrdItems(itemIds).size;
1669
- if (distinctPrdCount > 1 || pr?.itemType === 'verify' || pr?.title?.startsWith('[E2E]')) continue;
1681
+ if (distinctPrdCount > 1 || pr?.itemType === WORK_TYPE.VERIFY || pr?.title?.startsWith('[E2E]')) continue;
1670
1682
  const url = buildPrUrlFromId(prId, pr, projects);
1671
1683
  for (const itemId of (itemIds || [])) {
1672
1684
  if (!prdToPr[itemId]) prdToPr[itemId] = [];
@@ -30,23 +30,167 @@ const { safeJson, safeWrite, mutateJsonFileLocked, mutateScheduleRuns, ts, dateS
30
30
 
31
31
  const SCHEDULE_RUNS_PATH = path.join(shared.MINIONS_DIR, 'engine', 'schedule-runs.json');
32
32
 
33
+ /**
34
+ * Compute the ISO 8601 week of the year for `date` as `YYYY-Www` (e.g.
35
+ * 2026-W23). Uses the standard ISO algorithm: Thursday of the target week
36
+ * determines its calendar year; week 1 contains the first Thursday of the
37
+ * year. UTC-based so the value lines up with dateStamp().
38
+ */
39
+ function isoWeek(date) {
40
+ const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
41
+ // Day of week: Sunday=0..Saturday=6 → ISO Monday=1..Sunday=7.
42
+ const dayNum = d.getUTCDay() || 7;
43
+ d.setUTCDate(d.getUTCDate() + 4 - dayNum); // shift to Thursday of this week
44
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
45
+ const weekNum = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
46
+ return `${d.getUTCFullYear()}-W${String(weekNum).padStart(2, '0')}`;
47
+ }
48
+
49
+ /**
50
+ * Build the standard schedule-time template-var map. Vars are values that are
51
+ * KNOWN at schedule firing time (date, week, time, etc.) — not values that
52
+ * only become available later in the dispatch pipeline (project_name,
53
+ * branch_name, agent_id, etc.). Those still resolve in renderPlaybook.
54
+ *
55
+ * Caller-supplied `extraVars` override the time-based defaults so test
56
+ * harnesses can pin a specific date and so future callers can pass extras
57
+ * like `schedule_id`.
58
+ */
59
+ function buildScheduleTemplateVars(extraVars) {
60
+ const now = new Date();
61
+ const iso = now.toISOString();
62
+ const vars = {
63
+ date: dateStamp(), // 2026-06-11
64
+ year: iso.slice(0, 4), // 2026
65
+ month: iso.slice(5, 7), // 06
66
+ day: iso.slice(8, 10), // 11
67
+ time: iso.slice(11, 16), // HH:MM (UTC)
68
+ datetime: iso, // 2026-06-11T01:23:45.678Z
69
+ week: isoWeek(now), // 2026-W24
70
+ };
71
+ if (extraVars && typeof extraVars === 'object') {
72
+ for (const [k, v] of Object.entries(extraVars)) {
73
+ if (v === undefined || v === null) continue;
74
+ vars[k] = String(v);
75
+ }
76
+ }
77
+ return vars;
78
+ }
79
+
33
80
  /**
34
81
  * Substitute schedule-time template variables in a string.
35
- * Currently supports:
36
- * {{date}} today's date as YYYY-MM-DD (UTC, via dateStamp())
82
+ *
83
+ * Supported vars (BUG-H8 + BUG-H9 expansion of the original `{{date}}`-only
84
+ * helper):
85
+ * {{date}} — today's date as YYYY-MM-DD (UTC, via dateStamp())
86
+ * {{year}} — UTC year as YYYY
87
+ * {{month}} — UTC month as MM (zero-padded)
88
+ * {{day}} — UTC day of month as DD (zero-padded)
89
+ * {{time}} — UTC time as HH:MM
90
+ * {{datetime}} — full ISO timestamp (UTC, ms precision)
91
+ * {{week}} — ISO 8601 week as YYYY-Www (UTC)
92
+ *
93
+ * Plus any caller-supplied `extraVars` (string-coerced). Callers should only
94
+ * pass vars known at schedule time — pipeline-time vars like {{project_name}}
95
+ * stay in engine/playbook.js where the dispatch context is available.
37
96
  *
38
97
  * Downstream playbook rendering (engine/playbook.js) is a single-pass replace,
39
- * so any {{date}} embedded in a schedule's title/description would survive
98
+ * so any {{var}} embedded in a schedule's title/description would survive
40
99
  * substitution of {{task_description}} and surface as an "unresolved template
41
- * variables: date" warning plus a literal "{{date}}" in agent filenames.
42
- * Resolve these fields at schedule time so the work item carries a concrete
43
- * date string from the moment it's created.
100
+ * variables: var" warning plus a literal "{{var}}" in agent filenames.
101
+ * Resolve these fields at schedule time so the work item carries concrete
102
+ * strings from the moment it's created.
103
+ *
104
+ * Unknown placeholders are left intact (renderPlaybook still warns on them).
44
105
  *
45
106
  * Safe on undefined/null/empty/non-string inputs — returns the input unchanged.
107
+ *
108
+ * @param {*} str
109
+ * @param {object} [extraVars] optional caller-supplied vars (e.g. { schedule_id }).
110
+ * Can also be a pre-built var map from
111
+ * buildScheduleTemplateVars() to share one
112
+ * "now" snapshot across a recursive walk.
46
113
  */
47
- function resolveScheduleTemplateVars(str) {
114
+ function resolveScheduleTemplateVars(str, extraVars) {
48
115
  if (typeof str !== 'string' || str.length === 0) return str;
49
- return str.replace(/\{\{date\}\}/g, dateStamp());
116
+ if (str.indexOf('{{') === -1) return str;
117
+ const vars = (extraVars && extraVars.__scheduleVars) ? extraVars : buildScheduleTemplateVars(extraVars);
118
+ return str.replace(/\{\{(\w+)\}\}/g, (match, key) => {
119
+ return Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] : match;
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Recursively walk `obj` and substitute schedule-time template variables in
125
+ * every string-valued field in place. Returns `obj`.
126
+ *
127
+ * Walks own enumerable properties of plain objects and the items of plain
128
+ * arrays. Strings are replaced via resolveScheduleTemplateVars; null,
129
+ * primitives, Dates, RegExps, Buffers, Maps, Sets, functions, and any
130
+ * value with a custom prototype are left untouched.
131
+ *
132
+ * Cycle-safe: tracks visited objects so a self-referencing work-item field
133
+ * won't infinite-loop.
134
+ *
135
+ * Why a recursive walker exists (BUG-H8 + BUG-H9): scheduler's original fix
136
+ * only substituted `title` / `description` / `harness_rubric`. Other vars
137
+ * embedded in nested fields (e.g. `_harness.rubric`, `metadata.notes`,
138
+ * `references[].url`) survive untouched and surface as unresolved-template
139
+ * warnings or literal `{{var}}` strings in agent filenames. Apply this to
140
+ * each work item the scheduler hands off to dispatch.
141
+ *
142
+ * @param {*} obj work item or nested object/array to walk in place
143
+ * @param {object} [extraVars] optional caller-supplied vars passed through to
144
+ * resolveScheduleTemplateVars. A single var snapshot
145
+ * is taken at the top of the walk so every field
146
+ * sees the same "now".
147
+ */
148
+ function applyScheduleTemplateVarsRecursive(obj, extraVars) {
149
+ if (obj === null || typeof obj !== 'object') return obj;
150
+ // Take one snapshot of the var map so every field in the same walk sees
151
+ // an identical "now". Flag-mark it so resolveScheduleTemplateVars reuses
152
+ // it instead of rebuilding per call.
153
+ const vars = buildScheduleTemplateVars(extraVars);
154
+ Object.defineProperty(vars, '__scheduleVars', { value: true, enumerable: false });
155
+ const seen = new WeakSet();
156
+
157
+ function walk(node) {
158
+ if (node === null || typeof node !== 'object') return;
159
+ if (seen.has(node)) return;
160
+ seen.add(node);
161
+
162
+ if (Array.isArray(node)) {
163
+ for (let i = 0; i < node.length; i++) {
164
+ const v = node[i];
165
+ if (typeof v === 'string') {
166
+ node[i] = resolveScheduleTemplateVars(v, vars);
167
+ } else if (v !== null && typeof v === 'object') {
168
+ // Only recurse into plain objects/arrays — leave Date, Buffer, etc. alone.
169
+ const proto = Object.getPrototypeOf(v);
170
+ if (proto === Object.prototype || proto === Array.prototype || proto === null) {
171
+ walk(v);
172
+ }
173
+ }
174
+ }
175
+ return;
176
+ }
177
+
178
+ // Plain object: walk own enumerable string-keyed props.
179
+ for (const key of Object.keys(node)) {
180
+ const v = node[key];
181
+ if (typeof v === 'string') {
182
+ node[key] = resolveScheduleTemplateVars(v, vars);
183
+ } else if (v !== null && typeof v === 'object') {
184
+ const proto = Object.getPrototypeOf(v);
185
+ if (proto === Object.prototype || proto === Array.prototype || proto === null) {
186
+ walk(v);
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ walk(obj);
193
+ return obj;
50
194
  }
51
195
 
52
196
  // Parse a single cron field into a matcher function.
@@ -171,12 +315,12 @@ function createScheduledWorkItem(sched) {
171
315
  throw new Error('schedule id and title are required');
172
316
  }
173
317
  const workItemId = `sched-${sched.id}-${Date.now()}`;
174
- return {
318
+ const workItem = {
175
319
  id: workItemId,
176
- title: resolveScheduleTemplateVars(sched.title),
320
+ title: sched.title,
177
321
  type: routing.normalizeWorkType(sched.type, WORK_TYPE.IMPLEMENT),
178
322
  priority: sched.priority || 'medium',
179
- description: resolveScheduleTemplateVars(sched.description || sched.title),
323
+ description: sched.description || sched.title,
180
324
  status: WI_STATUS.PENDING,
181
325
  created: ts(),
182
326
  createdBy: 'scheduler',
@@ -185,6 +329,12 @@ function createScheduledWorkItem(sched) {
185
329
  project: sched.project || null,
186
330
  _scheduleId: sched.id,
187
331
  };
332
+ // Walk every string-valued field on the work item so vars embedded in
333
+ // nested fields (e.g. _harness.rubric added by callers, references[].url,
334
+ // ad-hoc metadata) all resolve before dispatch — not just title/description.
335
+ // Single-pass renderPlaybook can't reach into nested fields via
336
+ // {{task_description}} expansion (BUG-H8 + BUG-H9).
337
+ return applyScheduleTemplateVarsRecursive(workItem, { schedule_id: sched.id });
188
338
  }
189
339
 
190
340
  function writeScheduleRunEntry(runs, scheduleId, workItemId, extra) {
@@ -235,17 +385,26 @@ function discoverScheduledWork(config) {
235
385
  continue;
236
386
  }
237
387
  try {
238
- // Resolve schedule-time template variables on the title/description
239
- // BEFORE handing the schedule to the harness builder so subtask
240
- // prompts inherit the same substitutions as regular schedules.
241
- const resolvedSched = {
242
- ...sched,
243
- title: resolveScheduleTemplateVars(sched.title),
244
- description: resolveScheduleTemplateVars(sched.description || sched.title),
245
- harness_rubric: resolveScheduleTemplateVars(sched.harness_rubric),
246
- };
388
+ // Resolve schedule-time template variables on the schedule fields
389
+ // BEFORE handing them to the harness builder so subtask prompts
390
+ // inherit the same substitutions. The recursive walker catches any
391
+ // string field — not just title / description / harness_rubric —
392
+ // so e.g. caller-added metadata also resolves (BUG-H8 + BUG-H9).
393
+ const resolvedSched = applyScheduleTemplateVarsRecursive(
394
+ { ...sched },
395
+ { schedule_id: sched.id },
396
+ );
397
+ if (typeof resolvedSched.description !== 'string' || resolvedSched.description.length === 0) {
398
+ resolvedSched.description = resolvedSched.title;
399
+ }
247
400
  const mission = harness.createTriAgentMission(resolvedSched);
248
- for (const it of mission.items) work.push(it);
401
+ // Walk each generated harness work item too — _harness.rubric and
402
+ // other nested fields can carry surviving placeholders that the
403
+ // resolved-schedule step above doesn't reach.
404
+ for (const it of mission.items) {
405
+ applyScheduleTemplateVarsRecursive(it, { schedule_id: sched.id });
406
+ work.push(it);
407
+ }
249
408
  // Record the mission's planner id as lastWorkItemId for compatibility
250
409
  // with the existing schedule-runs shape, plus lastMissionId so the
251
410
  // dashboard and consolidation tooling can join across the trio.
@@ -288,5 +447,7 @@ module.exports = {
288
447
  recordScheduleRun,
289
448
  writeScheduleRunEntry,
290
449
  resolveScheduleTemplateVars,
450
+ applyScheduleTemplateVarsRecursive,
451
+ buildScheduleTemplateVars,
291
452
  SCHEDULE_RUNS_PATH,
292
453
  };
package/engine/shared.js CHANGED
@@ -3629,13 +3629,13 @@ function mutateMetrics(mutator) {
3629
3629
 
3630
3630
  /** Update per-agent review metrics (prsApproved/prsRejected). Only writes for configured agents. */
3631
3631
  function trackReviewMetric(pr, newReviewStatus, config) {
3632
- if (newReviewStatus !== 'approved' && newReviewStatus !== 'changes-requested') return;
3632
+ if (newReviewStatus !== REVIEW_STATUS.APPROVED && newReviewStatus !== REVIEW_STATUS.CHANGES_REQUESTED) return;
3633
3633
  const authorId = (pr.agent || '').toLowerCase();
3634
3634
  if (!authorId || !config?.agents?.[authorId]) return;
3635
3635
  try {
3636
3636
  mutateMetrics((metrics) => {
3637
3637
  if (!metrics[authorId]) metrics[authorId] = { ...DEFAULT_AGENT_METRICS };
3638
- if (newReviewStatus === 'approved') metrics[authorId].prsApproved = (metrics[authorId].prsApproved || 0) + 1;
3638
+ if (newReviewStatus === REVIEW_STATUS.APPROVED) metrics[authorId].prsApproved = (metrics[authorId].prsApproved || 0) + 1;
3639
3639
  else metrics[authorId].prsRejected = (metrics[authorId].prsRejected || 0) + 1;
3640
3640
  return metrics;
3641
3641
  });
@@ -3659,7 +3659,7 @@ function queuePlanToPrd({ planFile, prdFile, title, description, project, create
3659
3659
  let item = null;
3660
3660
  mutateJsonFileLocked(centralWiPath, items => {
3661
3661
  if (!Array.isArray(items)) items = [];
3662
- const existing = items.find(w => w.type === WORK_TYPE.PLAN_TO_PRD && w.planFile === planFile && (w.status === 'pending' || w.status === 'dispatched'));
3662
+ const existing = items.find(w => w.type === WORK_TYPE.PLAN_TO_PRD && w.planFile === planFile && (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.DISPATCHED));
3663
3663
  if (existing) {
3664
3664
  id = existing.id;
3665
3665
  item = existing;
@@ -3671,7 +3671,7 @@ function queuePlanToPrd({ planFile, prdFile, title, description, project, create
3671
3671
  type: WORK_TYPE.PLAN_TO_PRD,
3672
3672
  priority: 'high',
3673
3673
  description,
3674
- status: 'pending',
3674
+ status: WI_STATUS.PENDING,
3675
3675
  created: new Date().toISOString(),
3676
3676
  createdBy,
3677
3677
  project,
@@ -26,6 +26,7 @@ const path = require('path');
26
26
  const fs = require('fs');
27
27
  const http = require('http');
28
28
  const https = require('https');
29
+ const crypto = require('crypto');
29
30
  const { URL } = require('url');
30
31
 
31
32
  const shared = require('./shared');
@@ -33,6 +34,44 @@ const {
33
34
  WATCH_ACTION_TYPE, WI_STATUS, WORK_TYPE, DONE_STATUSES, PLAN_STATUS,
34
35
  log, ts, uid, mutateWorkItems, mutateJsonFileLocked, projectWorkItemsPath,
35
36
  } = shared;
37
+
38
+ // BUG-H10 (P-h10-watch-flood): statuses that mean the dispatched WI is still
39
+ // "live" — pending or in-flight — and should block a second dispatch for the
40
+ // same watch+target+condition+action identity. Terminal statuses (done,
41
+ // failed, cancelled, decomposed) DO NOT block; if the previous attempt
42
+ // terminated, a retrigger is allowed to fire fresh work.
43
+ const WATCH_DEDUP_ACTIVE_STATUSES = new Set([
44
+ WI_STATUS.PENDING,
45
+ WI_STATUS.DISPATCHED,
46
+ WI_STATUS.QUEUED,
47
+ WI_STATUS.PAUSED,
48
+ ]);
49
+
50
+ // BUG-H10 — derive a deterministic dedup key for a dispatched-work-item action
51
+ // invocation. Inputs are the stable identity of the trigger (watchId + target
52
+ // + condition) plus the resolved WI shape (title/type/project/agent). The key
53
+ // must NOT include Date.now()/uid()/triggerCount so the same logical work
54
+ // produces the same key across ticks and engine restarts.
55
+ //
56
+ // Stored on the dispatched WI under `meta._watchDedupKey`; engine/watch-actions.js
57
+ // scans existing pending+active WIs and skips the push if a match is found.
58
+ function computeWatchDedupKey(watch, dispatchedItemShape) {
59
+ const stable = {
60
+ target: watch && watch.target != null ? String(watch.target) : '',
61
+ targetType: watch && watch.targetType != null ? String(watch.targetType) : '',
62
+ condition: watch && watch.condition != null ? String(watch.condition) : '',
63
+ title: dispatchedItemShape.title || '',
64
+ type: dispatchedItemShape.type || '',
65
+ project: dispatchedItemShape.project || '',
66
+ agent: dispatchedItemShape.agent || '',
67
+ };
68
+ const hash = crypto.createHash('sha256')
69
+ .update(JSON.stringify(stable))
70
+ .digest('hex')
71
+ .slice(0, 16);
72
+ const watchId = (watch && watch.id) ? String(watch.id) : 'unknown';
73
+ return `${watchId}-${hash}`;
74
+ }
36
75
  // P-w7c5d8b3 — Phase 3.2: optional guard expressions on action steps.
37
76
  // safe-expr.evaluate() never throws and returns Boolean(...) on success or
38
77
  // `false` on parse/eval errors (with a `[safe-expr]` warn). That contract
@@ -384,11 +423,42 @@ registerActionType(WATCH_ACTION_TYPE.DISPATCH_WORK_ITEM, {
384
423
  if (project) item.project = project;
385
424
  if (p.agent) item.agent = String(p.agent);
386
425
 
426
+ // BUG-H10 — derive a stable dedup key (no Date.now/uid) and stamp it on
427
+ // meta._watchDedupKey so the in-mutator scan below can compare. A
428
+ // persistent watch condition (e.g. stuck-in-stage holding true for an
429
+ // hour at 1-tick-per-minute) would otherwise mint ~60 duplicate WIs.
430
+ const dedupKey = computeWatchDedupKey(watch, {
431
+ title,
432
+ type: item.type,
433
+ project,
434
+ agent: item.agent,
435
+ });
436
+ item.meta = Object.assign({}, item.meta, { _watchDedupKey: dedupKey });
437
+
387
438
  let appended = false;
439
+ let dedupedAgainstId = null;
388
440
  mutateWorkItems(wiPath, (items) => {
441
+ const existing = items.find(it =>
442
+ it
443
+ && it.meta
444
+ && it.meta._watchDedupKey === dedupKey
445
+ && WATCH_DEDUP_ACTIVE_STATUSES.has(it.status));
446
+ if (existing) {
447
+ dedupedAgainstId = existing.id;
448
+ return items;
449
+ }
389
450
  items.push(item);
390
451
  appended = true;
391
452
  });
453
+ if (dedupedAgainstId) {
454
+ log('info', `Watch ${watch.id} dispatch-work-item deduped against existing WI ${dedupedAgainstId} (key=${dedupKey})`);
455
+ return {
456
+ ok: true,
457
+ summary: `deduped against existing work item ${dedupedAgainstId}`,
458
+ dispatchedItemId: dedupedAgainstId,
459
+ deduped: true,
460
+ };
461
+ }
392
462
  if (!appended) return { ok: false, summary: 'dispatch-work-item: write failed' };
393
463
  log('info', `Watch ${watch.id} dispatched work item ${id} (${title})`);
394
464
  return { ok: true, summary: `dispatched work item ${id}`, dispatchedItemId: id };
@@ -1117,5 +1187,7 @@ module.exports = {
1117
1187
  buildTriggerContext,
1118
1188
  substituteTemplate,
1119
1189
  validateAction,
1190
+ computeWatchDedupKey, // exported for testing (BUG-H10)
1191
+ WATCH_DEDUP_ACTIVE_STATUSES, // exported for testing (BUG-H10)
1120
1192
  _ACTION_TYPES: ACTION_TYPES, // exported for testing
1121
1193
  };
package/engine/watches.js CHANGED
@@ -722,7 +722,7 @@ registerTargetType(WATCH_TARGET_TYPE.PR, {
722
722
  evaluate: (condition, pr, prevState, target) => {
723
723
  switch (condition) {
724
724
  case WATCH_CONDITION.MERGED:
725
- return { triggered: pr.status === 'merged', message: pr.status === 'merged' ? `PR ${target} was merged` : '' };
725
+ return { triggered: pr.status === shared.PR_STATUS.MERGED, message: pr.status === shared.PR_STATUS.MERGED ? `PR ${target} was merged` : '' };
726
726
  case WATCH_CONDITION.BUILD_FAIL:
727
727
  return { triggered: pr.buildStatus === shared.BUILD_STATUS.FAILING, message: pr.buildStatus === shared.BUILD_STATUS.FAILING ? `PR ${target} build is failing` : '' };
728
728
  case WATCH_CONDITION.BUILD_PASS:
@@ -910,7 +910,7 @@ registerTargetType(WATCH_TARGET_TYPE.WORK_ITEM, {
910
910
  });
911
911
 
912
912
  // Meeting — concluded (terminal status) / status-change / any
913
- const MEETING_TERMINAL = new Set(['completed', 'archived']);
913
+ const MEETING_TERMINAL = new Set([shared.MEETING_STATUS.COMPLETED, shared.MEETING_STATUS.ARCHIVED]);
914
914
  registerTargetType(WATCH_TARGET_TYPE.MEETING, {
915
915
  label: 'Meeting',
916
916
  description: 'Watch a meeting for conclusion or status changes',
@@ -1123,7 +1123,7 @@ function _completedStageCount(run) {
1123
1123
  let n = 0;
1124
1124
  for (const st of Object.values(run.stages)) {
1125
1125
  const s = String((st && st.status) || '').toLowerCase();
1126
- if (s === 'completed' || s === 'failed' || s === 'stopped') n += 1;
1126
+ if (s === shared.PIPELINE_STATUS.COMPLETED || s === shared.PIPELINE_STATUS.FAILED || s === shared.PIPELINE_STATUS.STOPPED) n += 1;
1127
1127
  }
1128
1128
  return n;
1129
1129
  }
@@ -1131,7 +1131,7 @@ function _completedStageCount(run) {
1131
1131
  // in run.stages iteration order. run.stages is built from pipeline.stages
1132
1132
  // in declaration order (engine/pipeline.js startRun ~line 84) so the first
1133
1133
  // non-terminal entry is the currently-executing or next-pending stage.
1134
- const _PIPELINE_TERMINAL = new Set(['completed', 'failed', 'stopped']);
1134
+ const _PIPELINE_TERMINAL = new Set([shared.PIPELINE_STATUS.COMPLETED, shared.PIPELINE_STATUS.FAILED, shared.PIPELINE_STATUS.STOPPED]);
1135
1135
  function _currentPipelineStageId(run) {
1136
1136
  if (!run || !run.stages || typeof run.stages !== 'object') return null;
1137
1137
  for (const [stageId, st] of Object.entries(run.stages)) {
@@ -1237,7 +1237,7 @@ function _findDispatchEntry(target, state) {
1237
1237
  }
1238
1238
  return null;
1239
1239
  }
1240
- const DISPATCH_TERMINAL = new Set(['done', 'completed', 'failed', 'cancelled']);
1240
+ const DISPATCH_TERMINAL = new Set([shared.WI_STATUS.DONE, shared.PIPELINE_STATUS.COMPLETED, shared.WI_STATUS.FAILED, shared.WI_STATUS.CANCELLED]);
1241
1241
  registerTargetType(WATCH_TARGET_TYPE.DISPATCH, {
1242
1242
  label: 'Dispatch',
1243
1243
  description: 'Watch a dispatch entry for terminal status or status changes',
package/engine.js CHANGED
@@ -31,7 +31,7 @@ const path = require('path');
31
31
  const crypto = require('crypto');
32
32
  const shared = require('./engine/shared');
33
33
  const { exec, execAsync, execSilent, runFile, ts, ENGINE_DEFAULTS,
34
- WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, DISPATCH_RESULT, AGENT_STATUS,
34
+ WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT, AGENT_STATUS,
35
35
  FAILURE_CLASS } = shared;
36
36
  const { resolveRuntime } = require('./engine/runtimes');
37
37
  const { assertStaleHeadOk } = require('./engine/spawn-agent');
@@ -1734,10 +1734,16 @@ async function spawnAgent(dispatchItem, config) {
1734
1734
  // (dispatch.js, meeting.js, cli.js, cleanup.js, timeout.js) can find the
1735
1735
  // dir via shared.dispatchPidCandidates / findDispatchPidFile. Best-effort —
1736
1736
  // backward-compat fallbacks still scan dispatch-<safeId>-* dirs by id.
1737
+ //
1738
+ // W-mq9b7lor (H1): must route through mutateDispatch (SQL writer) — a direct
1739
+ // mutateJsonFileLocked on engine/dispatch.json is dropped on the next
1740
+ // mutateDispatch call because engine/dispatch.js#mutateDispatch regenerates
1741
+ // dispatch.json from SQL after every successful mutation (see
1742
+ // engine/dispatch.js:98-101). Without the SQL write, the tmpDir pointer is
1743
+ // lost and orphan-reap/kill/cleanup paths can't find the prompt directory.
1737
1744
  try {
1738
1745
  dispatchItem.tmpDir = dispatchTmpDir;
1739
- const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
1740
- mutateJsonFileLocked(dispatchPath, (dispatch) => {
1746
+ mutateDispatch((dispatch) => {
1741
1747
  for (const queue of ['pending', 'active', 'completed']) {
1742
1748
  const arr = Array.isArray(dispatch?.[queue]) ? dispatch[queue] : null;
1743
1749
  if (!arr) continue;
@@ -1745,7 +1751,7 @@ async function spawnAgent(dispatchItem, config) {
1745
1751
  if (found) found.tmpDir = dispatchTmpDir;
1746
1752
  }
1747
1753
  return dispatch;
1748
- }, { defaultValue: { pending: [], active: [], completed: [] } });
1754
+ });
1749
1755
  } catch (e) { log('warn', `spawnAgent: failed to persist tmpDir for ${id}: ${e.message}`); }
1750
1756
  const _cleanupPromptFiles = () => { shared.removeDispatchTmpDir(dispatchTmpDir); };
1751
1757
  // Convert a WORKTREE_NESTED_IN_PROJECT throw into a fail-fast non-retryable
@@ -4787,15 +4793,39 @@ function materializePlansAsWorkItems(config) {
4787
4793
  if (desiredFileName && desiredFileName.toLowerCase() !== String(fileName).toLowerCase()) {
4788
4794
  const fromPath = path.join(PRD_DIR, fileName);
4789
4795
  const desiredPath = shared.sanitizePath(desiredFileName, PRD_DIR);
4790
- const toPath = shared.uniquePath(desiredPath);
4791
- try {
4792
- fs.renameSync(fromPath, toPath);
4793
- nextFileName = path.basename(toPath);
4794
- const migrated = migratePrdFilenameReferences(fileName, nextFileName);
4795
- if (migrated > 0) log('info', `Plan project enforcement: migrated ${migrated} PRD reference(s) from ${fileName} to ${nextFileName}`);
4796
- changed = true;
4797
- } catch (e) {
4798
- log('warn', `Plan project enforcement: could not rename ${fileName} to ${path.basename(toPath)}: ${e.message}`);
4796
+ // W-mq8qdai6 refuse to rename into a name owned by an archived PRD.
4797
+ // `shared.uniquePath` only scans the live `prd/` directory; without this
4798
+ // guard, an archived PRD with the canonical "<project>-<date>.json" name
4799
+ // would silently collide with the rename target. The dashboard joins
4800
+ // work items to PRDs by basename (sourcePlan === <prd filename>), so a
4801
+ // live↔archive name collision bleeds the archived PRD's done items,
4802
+ // verify task, and PRs into the live PRD's view (the bug-fix-plan vs
4803
+ // minions-opg-2026-06-10 incident).
4804
+ const archiveBasenames = new Set(
4805
+ safeReadDir(path.join(PRD_DIR, 'archive'))
4806
+ .filter(f => f.endsWith('.json'))
4807
+ .map(f => f.toLowerCase())
4808
+ );
4809
+ if (archiveBasenames.has(desiredFileName.toLowerCase())) {
4810
+ log('warn', `Plan project enforcement: skipping rename of ${fileName} to ${desiredFileName} — would collide with archived PRD`);
4811
+ } else {
4812
+ const toPath = shared.uniquePath(desiredPath);
4813
+ const toBasename = path.basename(toPath);
4814
+ if (archiveBasenames.has(toBasename.toLowerCase())) {
4815
+ // uniquePath bumped past a live conflict (e.g. <name>-2.json) but
4816
+ // the bumped name itself is owned by an archived PRD — same risk.
4817
+ log('warn', `Plan project enforcement: skipping rename of ${fileName} to ${toBasename} — would collide with archived PRD`);
4818
+ } else {
4819
+ try {
4820
+ fs.renameSync(fromPath, toPath);
4821
+ nextFileName = toBasename;
4822
+ const migrated = migratePrdFilenameReferences(fileName, nextFileName);
4823
+ if (migrated > 0) log('info', `Plan project enforcement: migrated ${migrated} PRD reference(s) from ${fileName} to ${nextFileName}`);
4824
+ changed = true;
4825
+ } catch (e) {
4826
+ log('warn', `Plan project enforcement: could not rename ${fileName} to ${toBasename}: ${e.message}`);
4827
+ }
4828
+ }
4799
4829
  }
4800
4830
  }
4801
4831
  if (changed) log('info', `Plan project enforcement: preserved declared project "${declaredProject}" for ${nextFileName}`);
@@ -5492,11 +5522,11 @@ async function discoverFromPrs(config, project) {
5492
5522
  const prNumber = shared.getPrNumber(pr);
5493
5523
  // Use reviewStatus as single source of truth (synced from ADO/GitHub votes)
5494
5524
  // minionsReview tracks metadata (reviewer, note) but not the authoritative status
5495
- const reviewStatus = pr.reviewStatus || 'pending';
5525
+ const reviewStatus = pr.reviewStatus || REVIEW_STATUS.PENDING;
5496
5526
 
5497
5527
  // Skip fix dispatch if a fix was recently submitted and awaiting re-review.
5498
5528
  // The poller holds reviewStatus at 'waiting' until the reviewer acts on the new code.
5499
- const awaitingReReview = reviewStatus === 'waiting' && !!pr.minionsReview?.fixedAt;
5529
+ const awaitingReReview = reviewStatus === REVIEW_STATUS.WAITING && !!pr.minionsReview?.fixedAt;
5500
5530
 
5501
5531
  // F8 (P-f8firstdispatch): hoisted above the first-review block so a
5502
5532
  // successful initial-review dispatch suppresses downstream fix dispatches
@@ -5509,7 +5539,7 @@ async function discoverFromPrs(config, project) {
5509
5539
  const reviewEnabled = evalLoopEnabled && pollEnabled && autoReviewPrs;
5510
5540
  const reReviewEnabled = evalLoopEnabled && pollEnabled && autoReReviewPrs;
5511
5541
  const alreadyReviewed = pr.lastReviewedAt && (!pr.lastPushedAt || pr.lastPushedAt <= pr.lastReviewedAt);
5512
- const needsReview = reviewEnabled && reviewStatus === 'pending' && !alreadyReviewed;
5542
+ const needsReview = reviewEnabled && reviewStatus === REVIEW_STATUS.PENDING && !alreadyReviewed;
5513
5543
  if (needsReview) {
5514
5544
  const key = getPrReviewCooldownKey('review', project, pr, prDisplayId);
5515
5545
  if (clearLegacyPrReviewCooldown('review', project, pr, prDisplayId, key)) {
@@ -5521,16 +5551,16 @@ async function discoverFromPrs(config, project) {
5521
5551
  try {
5522
5552
  const checkFn = project.repoHost === 'github' ? ghCheckLiveReview : adoCheckLiveReview;
5523
5553
  const liveStatus = await checkFn(pr, project);
5524
- if (liveStatus && liveStatus !== 'pending') {
5554
+ if (liveStatus && liveStatus !== REVIEW_STATUS.PENDING) {
5525
5555
  log('info', `Pre-dispatch vote check: ${pr.id} is ${liveStatus} (cached was pending) — skipping review`);
5526
5556
  // Never downgrade from approved
5527
- if (pr.reviewStatus !== 'approved') pr.reviewStatus = liveStatus;
5557
+ if (pr.reviewStatus !== REVIEW_STATUS.APPROVED) pr.reviewStatus = liveStatus;
5528
5558
  // Persist so next tick doesn't re-check
5529
5559
  try {
5530
5560
  mutateJsonFileLocked(projectPrPath(project), data => {
5531
5561
  if (!Array.isArray(data)) return data;
5532
5562
  const target = shared.findPrRecord(data, pr, project);
5533
- if (target && target.reviewStatus !== 'approved') target.reviewStatus = liveStatus;
5563
+ if (target && target.reviewStatus !== REVIEW_STATUS.APPROVED) target.reviewStatus = liveStatus;
5534
5564
  return data;
5535
5565
  });
5536
5566
  } catch (e) { log('warn', `persist live vote-check for ${pr.id}: ${e.message}`); }
@@ -5711,7 +5741,7 @@ async function discoverFromPrs(config, project) {
5711
5741
  // or when no minions review has completed yet (e.g. human-feedback-only fix path).
5712
5742
  const fixedAfterReview = !!(pr.minionsReview?.fixedAt &&
5713
5743
  (!pr.lastReviewedAt || pr.minionsReview.fixedAt > pr.lastReviewedAt));
5714
- const needsReReview = reReviewEnabled && reviewStatus === 'waiting' &&
5744
+ const needsReReview = reReviewEnabled && reviewStatus === REVIEW_STATUS.WAITING &&
5715
5745
  fixedAfterReview && !fixDispatched;
5716
5746
  if (needsReReview) {
5717
5747
  const key = getPrReviewCooldownKey('rereview', project, pr, prDisplayId);
@@ -5727,15 +5757,15 @@ async function discoverFromPrs(config, project) {
5727
5757
  try {
5728
5758
  const checkFn = project.repoHost === 'github' ? ghCheckLiveReview : adoCheckLiveReview;
5729
5759
  const liveStatus = await checkFn(pr, project);
5730
- const liveStatusBlocksReReview = liveStatus && liveStatus !== 'waiting' && liveStatus !== 'pending';
5760
+ const liveStatusBlocksReReview = liveStatus && liveStatus !== REVIEW_STATUS.WAITING && liveStatus !== REVIEW_STATUS.PENDING;
5731
5761
  if (liveStatusBlocksReReview) {
5732
5762
  log('info', `Pre-dispatch vote check: ${pr.id} is ${liveStatus} (cached was waiting) — skipping re-review`);
5733
- if (pr.reviewStatus !== 'approved') pr.reviewStatus = liveStatus;
5763
+ if (pr.reviewStatus !== REVIEW_STATUS.APPROVED) pr.reviewStatus = liveStatus;
5734
5764
  try {
5735
5765
  mutateJsonFileLocked(projectPrPath(project), data => {
5736
5766
  if (!Array.isArray(data)) return data;
5737
5767
  const target = shared.findPrRecord(data, pr, project);
5738
- if (target && target.reviewStatus !== 'approved') target.reviewStatus = liveStatus;
5768
+ if (target && target.reviewStatus !== REVIEW_STATUS.APPROVED) target.reviewStatus = liveStatus;
5739
5769
  return data;
5740
5770
  });
5741
5771
  } catch (e) { log('warn', `persist live re-review vote-check for ${pr.id}: ${e.message}`); }
@@ -5776,7 +5806,7 @@ async function discoverFromPrs(config, project) {
5776
5806
 
5777
5807
  // PRs with changes requested → route back to author for fix.
5778
5808
  // Gate on evalLoopEnabled and provider polling — the review→fix cycle depends on fresh vote state.
5779
- if (evalLoopEnabled && pollEnabled && autoFixReviewFeedback && reviewStatus === 'changes-requested' && !awaitingReReview && !fixDispatched
5809
+ if (evalLoopEnabled && pollEnabled && autoFixReviewFeedback && reviewStatus === REVIEW_STATUS.CHANGES_REQUESTED && !awaitingReReview && !fixDispatched
5780
5810
  && !isPrNoOpFixCauseSuppressed(pr, shared.PR_FIX_CAUSE.REVIEW_FEEDBACK)) {
5781
5811
  const reviewCauseKey = getPrAutomationCauseKey('review-feedback', pr);
5782
5812
  const key = getPrAutomationDispatchKey(`fix-${project?.name || 'default'}-${prDisplayId}`, reviewCauseKey);
@@ -6473,6 +6503,16 @@ function discoverFromWorkItems(config, project) {
6473
6503
 
6474
6504
  const root = project?.localPath ? path.resolve(project.localPath) : path.resolve(MINIONS_DIR, '..');
6475
6505
  const items = safeJsonArr(projectWorkItemsPath(project));
6506
+ // W-mq9b7lor (H3): snapshot each item's serializable state BEFORE the
6507
+ // discover loop runs so we can compute per-item field deltas at the end
6508
+ // and apply them inside the mutateWorkItems lock — the prior
6509
+ // `mutateWorkItems(..., () => items)` shape ignored the lock-supplied
6510
+ // current state and overwrote any concurrent writes that landed while
6511
+ // discoverWork was iterating (e.g. dashboard PATCHes, completion writes,
6512
+ // sibling-project work-item store mutations sharing the same scope).
6513
+ const _wiPreSnapshots = new Map(
6514
+ items.map(it => [it.id, JSON.parse(JSON.stringify(it))])
6515
+ );
6476
6516
  const cooldownMs = (src.cooldownMinutes || 0) * 60 * 1000;
6477
6517
  const newWork = [];
6478
6518
  // PRD sync for dispatched status deferred to spawnAgent success (#480)
@@ -6824,8 +6864,47 @@ function discoverFromWorkItems(config, project) {
6824
6864
  }
6825
6865
 
6826
6866
  // Write back updated statuses (pendingReason clears, checkpoint counts, decompose flags, etc.)
6867
+ // W-mq9b7lor (H3): compute per-item field deltas from the pre-loop snapshot
6868
+ // and apply them INSIDE the mutator against the lock-supplied `current`
6869
+ // array, so concurrent writers (dashboard PATCHes, sibling discoverWork
6870
+ // passes, completion writes routed through the same work-items store
6871
+ // scope) that landed during the discover loop survive. The prior `() =>
6872
+ // items` shape ignored `current` and committed the pre-lock snapshot,
6873
+ // silently overwriting those concurrent changes.
6827
6874
  if (needsWrite) {
6828
- mutateWorkItems(projectWorkItemsPath(project), () => items);
6875
+ const patches = new Map();
6876
+ for (const item of items) {
6877
+ const before = _wiPreSnapshots.get(item.id);
6878
+ if (!before) continue; // safety: discover doesn't add new items
6879
+ const setFields = {};
6880
+ const deleteKeys = [];
6881
+ const allKeys = new Set([...Object.keys(before), ...Object.keys(item)]);
6882
+ for (const k of allKeys) {
6883
+ const hasBefore = Object.prototype.hasOwnProperty.call(before, k);
6884
+ const hasAfter = Object.prototype.hasOwnProperty.call(item, k);
6885
+ if (hasAfter && !hasBefore) {
6886
+ setFields[k] = item[k];
6887
+ } else if (!hasAfter && hasBefore) {
6888
+ deleteKeys.push(k);
6889
+ } else if (JSON.stringify(before[k]) !== JSON.stringify(item[k])) {
6890
+ setFields[k] = item[k];
6891
+ }
6892
+ }
6893
+ if (Object.keys(setFields).length > 0 || deleteKeys.length > 0) {
6894
+ patches.set(item.id, { setFields, deleteKeys });
6895
+ }
6896
+ }
6897
+ if (patches.size > 0) {
6898
+ mutateWorkItems(projectWorkItemsPath(project), (current) => {
6899
+ for (const it of current) {
6900
+ const patch = patches.get(it.id);
6901
+ if (!patch) continue;
6902
+ Object.assign(it, patch.setFields);
6903
+ for (const k of patch.deleteKeys) delete it[k];
6904
+ }
6905
+ return current;
6906
+ });
6907
+ }
6829
6908
  }
6830
6909
 
6831
6910
  const skipTotal = skipped.gated + skipped.noAgent;
@@ -7054,11 +7133,11 @@ function materializeSpecsAsWorkItems(config, project) {
7054
7133
 
7055
7134
  existingItems.push({
7056
7135
  id: newId,
7057
- type: 'implement',
7136
+ type: WORK_TYPE.IMPLEMENT,
7058
7137
  title: `Implement: ${info.title}`,
7059
7138
  description: `Implementation work from merged spec.\n\n**Spec:** \`${doc.file}\`\n**Source PR:** ${pr.id} — ${pr.title || ''}\n**PR URL:** ${pr.url || 'N/A'}\n\n## Summary\n\n${info.summary}\n\nRead the full spec at \`${doc.file}\` before starting.`,
7060
7139
  priority: info.priority,
7061
- status: 'queued',
7140
+ status: WI_STATUS.QUEUED,
7062
7141
  created: ts(),
7063
7142
  createdBy: 'engine:spec-discovery',
7064
7143
  sourceSpec: doc.file,
@@ -8279,30 +8358,31 @@ async function tickInner() {
8279
8358
 
8280
8359
  try { pruneStalePrDispatches(config); } catch (e) { log('warn', 'prune stale PR dispatches: ' + e.message); }
8281
8360
 
8282
- // 3. Process pending dispatches before discovery. discoverWork() runs
8361
+ // Process pending dispatches before discovery. discoverWork() runs
8283
8362
  // pre-dispatch LLM validation for newly found work; a slow validator must not
8284
8363
  // starve runnable entries that are already durable in dispatch.pending.
8285
- const dispatch = getDispatch();
8286
- const activeCount = (dispatch.active || []).length;
8364
+ //
8365
+ // W-mq9b7lor (H2): sort INSIDE the dispatch mutator so any concurrent
8366
+ // completion / enqueue / queue-cleanup path that lands between the SQL read
8367
+ // and our write isn't clobbered by writing a stale pre-lock snapshot back.
8368
+ // Previously this site read dispatch, sorted pending in memory, then handed
8369
+ // the pre-lock snapshot to the mutator callback — overwriting whatever the
8370
+ // lock-held read had freshly loaded from SQL.
8287
8371
  const maxConcurrent = resolveMaxConcurrent(config);
8288
-
8289
- const slotsAvailable = Math.max(0, maxConcurrent - activeCount);
8290
-
8291
- // Priority dispatch: implement > fix/ask > review > test/verify > plan > other
8292
8372
  const typePriority = { 'implement:large': 0, implement: 0, fix: 1, ask: 1, review: 2, test: 3, verify: 3, plan: 4, 'plan-to-prd': 4 };
8293
8373
  const itemPriority = { high: 0, medium: 1, low: 2 };
8294
- dispatch.pending.sort((a, b) => {
8295
- const ta = typePriority[a.type] ?? 5, tb = typePriority[b.type] ?? 5;
8296
- if (ta !== tb) return ta - tb;
8297
- const pa = itemPriority[a.meta?.item?.priority] ?? 1, pb = itemPriority[b.meta?.item?.priority] ?? 1;
8298
- return pa - pb;
8299
- });
8300
8374
  if (_isTickStale(myGeneration)) return;
8301
- mutateDispatch((dp) => {
8302
- dp.pending = dispatch.pending;
8303
- dp.active = dispatch.active || dp.active;
8375
+ const dispatch = mutateDispatch((dp) => {
8376
+ dp.pending.sort((a, b) => {
8377
+ const ta = typePriority[a.type] ?? 5, tb = typePriority[b.type] ?? 5;
8378
+ if (ta !== tb) return ta - tb;
8379
+ const pa = itemPriority[a.meta?.item?.priority] ?? 1, pb = itemPriority[b.meta?.item?.priority] ?? 1;
8380
+ return pa - pb;
8381
+ });
8304
8382
  return dp;
8305
8383
  });
8384
+ const activeCount = (dispatch.active || []).length;
8385
+ const slotsAvailable = Math.max(0, maxConcurrent - activeCount);
8306
8386
 
8307
8387
  // Build set of agents currently active (one task per agent at a time).
8308
8388
  const busyAgents = new Set((dispatch.active || []).map(d => d.agent));
@@ -8658,7 +8738,35 @@ async function tickInner() {
8658
8738
  }
8659
8739
  if (skipReasonChanged) {
8660
8740
  if (_isTickStale(myGeneration)) return;
8661
- mutateDispatch((dp) => { dp.pending = postDispatch.pending; return dp; });
8741
+ // W-mq9b7lor (H2): apply per-item skipReason patches INSIDE the mutator
8742
+ // so any concurrent completion / enqueue / queue-cleanup path landing
8743
+ // between the postDispatch snapshot read above and our write isn't
8744
+ // clobbered. Previously the mutator callback assigned dp.pending from the
8745
+ // pre-lock postDispatch snapshot, overwriting any freshly loaded queue
8746
+ // entries. Sentinel-undefined for _pendingReason / _agentBusySince
8747
+ // mirrors the in-loop `delete` semantics — JSON serialization drops
8748
+ // undefined-valued keys, so SQL → JSON mirror stays consistent.
8749
+ const patches = new Map();
8750
+ for (const item of (postDispatch.pending || [])) {
8751
+ patches.set(item.id, {
8752
+ skipReason: item.skipReason,
8753
+ _pendingReason: item._pendingReason,
8754
+ _agentBusySince: item._agentBusySince,
8755
+ });
8756
+ }
8757
+ if (_isTickStale(myGeneration)) return;
8758
+ mutateDispatch((dp) => {
8759
+ for (const item of (dp.pending || [])) {
8760
+ const patch = patches.get(item.id);
8761
+ if (!patch) continue;
8762
+ item.skipReason = patch.skipReason;
8763
+ if (patch._pendingReason === undefined) delete item._pendingReason;
8764
+ else item._pendingReason = patch._pendingReason;
8765
+ if (patch._agentBusySince === undefined) delete item._agentBusySince;
8766
+ else item._agentBusySince = patch._agentBusySince;
8767
+ }
8768
+ return dp;
8769
+ });
8662
8770
  }
8663
8771
 
8664
8772
  // 4. Discover new work from sources. Newly discovered dispatches are eligible
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2170",
3
+ "version": "0.1.2172",
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"